From 9d2eda0025085007f4068faa23008fa4fe320bc4 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:31:37 +0800 Subject: [PATCH 1/2] fix(picker): defer fzf-lua finder for optimal list formatting --- lua/opencode/ui/base_picker.lua | 105 +++++++++++++++++++------------- tests/unit/base_picker_spec.lua | 18 ++++++ 2 files changed, 80 insertions(+), 43 deletions(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 50b731b9..94d21918 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -315,6 +315,54 @@ end local function fzf_ui(opts) local fzf_lua = require('fzf-lua') + local function finder(fzf_cb, width) + for idx, item in ipairs(opts.items) do + local line_str = opts.format_fn(item, width):to_string() + + -- Prepend index with SOH delimiter for reliable matching + local indexed_line = tostring(idx) .. '\x01' .. line_str + + -- For file preview support, append file:line:col format + -- fzf-lua's builtin previewer automatically parses this format + if opts.preview == 'file' and type(item) == 'table' then + local file_path = item.file_path or item.path or item.filename or item.file + local line = item.line or item.lnum + local col = item.column or item.col + + if file_path then + -- fzf-lua parses "path:line:col:" format for preview positioning + local pos_info = file_path + if line then + pos_info = pos_info .. ':' .. tostring(line) + if col then + pos_info = pos_info .. ':' .. tostring(col) + end + pos_info = pos_info .. ':' + end + -- Append position info after nbsp separator (fzf-lua standard) + -- nbsp is U+2002 EN SPACE, not regular tab + local nbsp = '\xe2\x80\x82' + indexed_line = indexed_line .. nbsp .. pos_info + end + end + + fzf_cb(indexed_line) + end + fzf_cb() + end + + local has_custom_preview = opts.preview == 'custom' and opts.preview_fn ~= nil + local format_width + + -- defer item processing until preview if using custom preview_fn + -- so we can pass the width of the fzf window to the format_fn for optimal formatting + local width_callback = has_custom_preview + and function(_, _, _, ctx) + format_width = (ctx.env.FZF_COLUMNS or vim.o.columns) - (ctx.env.FZF_PREVIEW_COLUMNS or 0) + format_width = math.min(format_width, vim.o.columns - 8) + end + or nil + ---@return table local function create_fzf_config() local has_multi_action = util.some(opts.actions, function(action) @@ -322,6 +370,9 @@ local function fzf_ui(opts) end) return { + fzf_cli_args = width_callback and ('--bind=' .. require('fzf-lua.libuv').shellescape( + 'start:+transform:' .. require('fzf-lua.shell').stringify_data(width_callback, opts) + )) or nil, winopts = opts.width and { width = opts.width + 8, -- extra space for fzf UI } or nil, @@ -335,7 +386,7 @@ local function fzf_ui(opts) previewer = (function() if opts.preview == 'file' then return 'builtin' - elseif opts.preview == 'custom' and opts.preview_fn then + elseif has_custom_preview then return { _ctor = function() local previewer = require('fzf-lua.previewer.builtin').buffer_or_file:extend() @@ -372,45 +423,6 @@ local function fzf_ui(opts) } end - ---@return fun(fzf_cb: fun(line?: string)) - local function create_finder() - return function(fzf_cb) - for idx, item in ipairs(opts.items) do - local line_str = opts.format_fn(item):to_string() - - -- Prepend index with SOH delimiter for reliable matching - local indexed_line = tostring(idx) .. '\x01' .. line_str - - -- For file preview support, append file:line:col format - -- fzf-lua's builtin previewer automatically parses this format - if opts.preview == 'file' and type(item) == 'table' then - local file_path = item.file_path or item.path or item.filename or item.file - local line = item.line or item.lnum - local col = item.column or item.col - - if file_path then - -- fzf-lua parses "path:line:col:" format for preview positioning - local pos_info = file_path - if line then - pos_info = pos_info .. ':' .. tostring(line) - if col then - pos_info = pos_info .. ':' .. tostring(col) - end - pos_info = pos_info .. ':' - end - -- Append position info after nbsp separator (fzf-lua standard) - -- nbsp is U+2002 EN SPACE, not regular tab - local nbsp = '\xe2\x80\x82' - indexed_line = indexed_line .. nbsp .. pos_info - end - end - - fzf_cb(indexed_line) - end - fzf_cb() - end - end - ---Reopen fzf-lua to reflect updated picker items. local function refresh_fzf() vim.schedule(function() @@ -515,7 +527,14 @@ local function fzf_ui(opts) local fzf_config = create_fzf_config() fzf_config.actions = actions_config - fzf_lua.fzf_exec(create_finder(), fzf_config) + fzf_lua.fzf_exec(function(fzf_cb) + if width_callback then + vim.wait(1000, function() + return format_width + end) + end + finder(fzf_cb, format_width) + end, fzf_config) end ---Mini.pick UI implementation @@ -888,8 +907,8 @@ function M.pick(opts) end local original_format_fn = opts.format_fn - opts.format_fn = function(item) - return original_format_fn(item, format_width) + opts.format_fn = function(item, width) + return original_format_fn(item, width or format_width) end local title_str = type(opts.title) == 'function' and opts.title() or opts.title --[[@as string]] diff --git a/tests/unit/base_picker_spec.lua b/tests/unit/base_picker_spec.lua index e9b039d1..61fdebfa 100644 --- a/tests/unit/base_picker_spec.lua +++ b/tests/unit/base_picker_spec.lua @@ -252,6 +252,8 @@ describe('opencode.ui.base_picker fzf-lua preview', function() ['opencode.ui.picker'] = package.loaded['opencode.ui.picker'], ['opencode.ui.base_picker'] = package.loaded['opencode.ui.base_picker'], ['fzf-lua'] = package.loaded['fzf-lua'], + ['fzf-lua.libuv'] = package.loaded['fzf-lua.libuv'], + ['fzf-lua.shell'] = package.loaded['fzf-lua.shell'], ['fzf-lua.previewer.builtin'] = package.loaded['fzf-lua.previewer.builtin'], } @@ -293,6 +295,7 @@ describe('opencode.ui.base_picker fzf-lua preview', function() captured_fzf_finder = nil captured_fzf_opts = nil + captured_transform = nil package.loaded['fzf-lua'] = { fzf_exec = function(finder, opts) @@ -302,6 +305,19 @@ describe('opencode.ui.base_picker fzf-lua preview', function() } next_preview_buf = nil + package.loaded['fzf-lua.libuv'] = { + shellescape = function(s) + return s + end, + } + + package.loaded['fzf-lua.shell'] = { + stringify_data = function(fn) + captured_transform = fn + return '' + end, + } + package.loaded['fzf-lua.previewer.builtin'] = { buffer_or_file = { extend = function() @@ -413,6 +429,8 @@ describe('opencode.ui.base_picker fzf-lua preview', function() end end) + captured_transform(nil, nil, nil, { env = { FZF_COLUMNS = 31 } }) + assert.equal(31, observed_width) assert.are.same({ '1\001session' }, emitted_lines) assert.equal(88, captured_fzf_opts.winopts.width) From 04793b3d326b81a560b3201a51c20baf07679485 Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:31:41 +0800 Subject: [PATCH 2/2] feat(session): cross-project session list and session lock Preserve the active session across DirChanged via a session lock toggle, and have the session picker list all projects from /experimental/session when locked (bypassing the api_client's directory injection). --- lua/opencode/api.lua | 1 + lua/opencode/api_client.lua | 11 ++++ lua/opencode/commands/handlers/session.lua | 53 +++++++++++++++++--- lua/opencode/config.lua | 1 + lua/opencode/services/session_runtime.lua | 47 +++++++++++++++-- lua/opencode/session.lua | 15 ++++++ lua/opencode/state/session.lua | 21 ++++++++ lua/opencode/state/store.lua | 2 + lua/opencode/types.lua | 10 ++++ lua/opencode/ui/session_picker.lua | 32 ++++++++---- lua/opencode/ui/ui.lua | 5 +- tests/unit/commands_handlers_spec.lua | 2 +- tests/unit/services_session_runtime_spec.lua | 31 ++++++++++++ 13 files changed, 206 insertions(+), 25 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 58e98b25..e09730f3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -53,6 +53,7 @@ local action_groups = { rename_session = session.rename_session, undo = session.undo, fork_session = session.fork_session, + toggle_session_lock = session.toggle_session_lock, }, diff = { diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 2a7b5afa..fedab4fe 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -195,6 +195,17 @@ function OpencodeApiClient:list_sessions(directory) return self:_call('/session', 'GET', nil, { directory = directory }) end +--- List sessions across all projects (experimental global endpoint). +--- Bypasses _call's automatic directory injection so the server returns all +--- directories instead of being filtered to the current cwd. +--- @return Promise +function OpencodeApiClient:list_sessions_global() + if not self:_ensure_base_url() then + return require('opencode.promise').new():reject('No server base url') + end + return server_job.call_api(self.base_url .. '/experimental/session', 'GET') +end + --- Create a new session --- @param session_data {parentID?: string, title?: string}|nil|boolean Session creation data --- @param directory string|nil Directory path diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index 34e38754..3c7c3aeb 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', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename' } +local session_subcommands = { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename', 'toggle_lock' } ---@param message string local function invalid_arguments(message) @@ -94,8 +94,27 @@ function M.actions.open_input_new_session_with_title(title) end ---@param parent_id? string -function M.actions.select_session(parent_id) - session_runtime.select_session(parent_id) +---@param scope? 'project' | 'global' defaults to global when session is locked, project otherwise +function M.actions.select_session(parent_id, scope) + if scope == nil then + scope = session_runtime.is_session_locked() and 'global' or 'project' + end + session_runtime.select_session(parent_id, scope) +end + +---@param value? boolean if nil toggle, otherwise set to value +function M.actions.toggle_session_lock(value) + local new_value + if value == nil then + new_value = session_runtime.toggle_session_lock() + else + new_value = session_runtime.set_session_lock(value and true or false) + end + vim.notify( + 'Session lock ' .. (new_value and 'enabled (session preserved across cwd changes)' or 'disabled'), + vim.log.levels.INFO + ) + return new_value end local NAV_DIRECTIONS = { parent = true, child = true, sibling = true, forward = true, backward = true } @@ -189,7 +208,7 @@ function M.actions.navigate_session_tree(direction, interaction, wrap, empty_pol return end if interaction == 'picker' then - return session_runtime.select_session(direction) + return session_runtime.select_session(direction, 'project') end return session_runtime.switch_session(direction) end @@ -207,7 +226,7 @@ function M.actions.navigate_session_tree(direction, interaction, wrap, empty_pol local target_id = dir.get_target(active) if not target_id then if direction == 'sibling' then - return session_runtime.select_session(nil) + return session_runtime.select_session(nil, 'project') end if empty_policy == 'notify' then vim.notify('No ' .. direction, vim.log.levels.INFO) @@ -215,7 +234,7 @@ function M.actions.navigate_session_tree(direction, interaction, wrap, empty_pol return end if interaction == 'picker' or not dir.allow_direct then - return session_runtime.select_session(target_id) + return session_runtime.select_session(target_id, 'project') end return session_runtime.switch_session(target_id) end @@ -575,11 +594,25 @@ local session_subcommand_actions = { agents_init = function() return M.actions.initialize() end, + toggle_lock = function(args) + local raw = args[2] + local value + if raw == nil or raw == '' then + value = nil + elseif raw == 'true' or raw == 'on' or raw == '1' then + value = true + elseif raw == 'false' or raw == 'off' or raw == '0' then + value = false + else + invalid_arguments('Invalid toggle_lock argument: ' .. tostring(raw)) + end + return M.actions.toggle_session_lock(value) + end, } M.command_defs = { session = { - desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename)', + desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename/toggle_lock)', completions = session_subcommands, nested_subcommand = { allow_empty = false }, execute = function(args) @@ -593,6 +626,12 @@ 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 }, + toggle_session_lock = { + desc = 'Toggle session lock (preserve active session across cwd changes)', + execute = function(args) + return M.actions.toggle_session_lock(args[1]) + end, + }, select_session = { desc = 'Select session', execute = function() diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 0cec4efb..3ae5afea 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -13,6 +13,7 @@ M.defaults = { default_system_prompt = nil, keymap_prefix = 'o', opencode_executable = 'opencode', + lock_session_to_directory = false, server = { url = nil, port = nil, diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index e77f51df..8c439100 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -13,6 +13,27 @@ local agent_model = require('opencode.services.agent_model') local M = {} +---@return boolean +function M.is_session_locked() + local explicit = state.store.get('session_locked') + if explicit ~= nil then + return explicit + end + return config.lock_session_to_directory == true +end + +---@param value boolean +---@return boolean +function M.set_session_lock(value) + state.session.set_locked(value and true or false) + return M.is_session_locked() +end + +---@return boolean new_value +function M.toggle_session_lock() + return M.set_session_lock(not M.is_session_locked()) +end + local function focus_after_session_switch(selected_session) if not state.ui.is_visible() then M.open() @@ -34,8 +55,14 @@ local function focus_after_session_switch(selected_session) end ---@param parent_id string? -M.select_session = Promise.async(function(parent_id) - local all_sessions = session.get_all_workspace_sessions():await() or {} +---@param scope? 'project' | 'global' when nil, defaults to project-scoped +M.select_session = Promise.async(function(parent_id, scope) + local all_sessions + if scope == 'global' then + all_sessions = session.get_all_global_sessions():await() or {} + else + all_sessions = session.get_all_workspace_sessions():await() or {} + end ---@cast all_sessions Session[] local filtered_sessions = vim.tbl_filter(function(s) @@ -58,7 +85,7 @@ M.select_session = Promise.async(function(parent_id) return end M.switch_session(selected_session.id) - end) + end, { scope = scope }) end) M.switch_session = Promise.async(function(session_id) @@ -93,6 +120,9 @@ M.check_cwd = function() { current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() } ) state.context.set_current_cwd(vim.fn.getcwd()) + if M.is_session_locked() then + return + end state.session.clear_active() context.unload_attachments() end @@ -312,7 +342,16 @@ end) M.handle_directory_change = Promise.async(function() local cwd = vim.fn.getcwd() - log.debug('Working directory change %s', vim.inspect({ cwd = cwd })) + log.debug('Working directory change %s', vim.inspect({ cwd = cwd, locked = M.is_session_locked() })) + + if M.is_session_locked() and state.active_session then + vim.notify( + 'Session locked, staying on [' .. state.active_session.id .. '] in new working dir [' .. cwd .. ']', + vim.log.levels.INFO + ) + return + end + vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) state.session.clear_active() diff --git a/lua/opencode/session.lua b/lua/opencode/session.lua index 00270cfe..77400e8f 100644 --- a/lua/opencode/session.lua +++ b/lua/opencode/session.lua @@ -66,6 +66,21 @@ M.get_all_workspace_sessions = Promise.async(function() return sessions end) +---Get all sessions across every project (no workspace filter) +---@return GlobalSession[]|nil +M.get_all_global_sessions = Promise.async(function() + local sessions = state.api_client:list_sessions_global():await() + if not sessions or type(sessions) ~= 'table' then + return nil + end + + table.sort(sessions, function(a, b) + return a.time.updated > b.time.updated + end) + + return sessions +end) + ---Get the most recent main workspace session ---@return Session|nil M.get_last_workspace_session = Promise.async(function() diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index 14e716f3..6576b75c 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -22,6 +22,27 @@ function M.clear_active() end) end +---@return boolean +function M.is_locked() + return store.get('session_locked') == true +end + +---@param value boolean|nil nil = inherit default +function M.set_locked(value) + if value == nil then + store.set_raw('session_locked', nil) + else + store.set('session_locked', value and true or false) + end +end + +---@return boolean new_value +function M.toggle_locked() + local new_value = not M.is_locked() + M.set_locked(new_value) + return new_value +end + ---@param points RestorePoint[] function M.set_restore_points(points) return store.set('restore_points', points) diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index b2e3f63f..ce5fd226 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -41,6 +41,7 @@ local M = {} ---@field required_version string ---@field opencode_cli_version string|nil ---@field current_cwd string|nil +---@field session_locked boolean|nil ---@field _hidden_buffers OpencodeHiddenBuffers|nil ---@type OpencodeStateData @@ -83,6 +84,7 @@ local _state = { required_version = '0.6.3', opencode_cli_version = nil, current_cwd = vim.fn.getcwd(), + session_locked = nil, _hidden_buffers = nil, } diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index b8b0ed60..e8d0173a 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -128,9 +128,18 @@ ---@field parentID string|nil ---@field agent string|nil ---@field model { id: string, providerID: string, variant?: string }|nil +---@field directory? string ---@field revert? SessionRevertInfo ---@field share? SessionShareInfo +---@class SessionProjectInfo +---@field id string +---@field name? string +---@field worktree string + +---@class GlobalSession : Session +---@field project SessionProjectInfo|nil + ---@class OpencodeKeymapEntry ---@field [1] string # Function name ---@field mode? string|string[] # Mode(s) for the keymap @@ -362,6 +371,7 @@ ---@field default_system_prompt string | nil ---@field keymap_prefix string ---@field opencode_executable 'opencode' | string -- Command run for calling opencode +---@field lock_session_to_directory boolean -- If true, active session is preserved across DirChanged events ---@field server OpencodeServerConfig -- Custom/external server configuration ---@field keymap OpencodeKeymap ---@field ui OpencodeUIConfig diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index c62f2b20..bdab2ed3 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -28,11 +28,18 @@ function M._is_session_or_ancestor_deleted(session_id, delete_ids, all_sessions) end ---Format session parts for session picker ----@param session Session object +---@param session Session|GlobalSession object +---@param width? integer ---@return PickerItem function format_session_item(session, width) + local project = (session --[[@as GlobalSession]]).project + local title = session.title or 'N/A' + if project then + local label = project.name or vim.fn.pathshorten(project.worktree) or project.id or '?' + title = title .. ' [' .. label .. ']' + end local updated_time = (session.time and session.time.updated) or 'N/A' - return base_picker.create_time_picker_item(session.title, updated_time, nil, width) + return base_picker.create_time_picker_item(title, updated_time, nil, width) end --- Normalize message order to oldest-first (chronological) @@ -199,12 +206,12 @@ local function render_preview_buffer(target, formatted) -- which requires a 3-column gutter (signcolumn=yes + foldcolumn=1) so -- the border renders in the gutter instead of overlaying column 0 of text. target:with_window(function() - vim.api.nvim_set_option_value('number', false, { win = 0 }) - vim.api.nvim_set_option_value('relativenumber', false, { win = 0 }) - vim.api.nvim_set_option_value('signcolumn', 'yes', { win = 0 }) - vim.api.nvim_set_option_value('foldcolumn', '1', { win = 0 }) - vim.api.nvim_set_option_value('statuscolumn', '', { win = 0 }) - vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 }) + vim.api.nvim_set_option_value('number', false, { win = 0 }) + vim.api.nvim_set_option_value('relativenumber', false, { win = 0 }) + vim.api.nvim_set_option_value('signcolumn', 'yes', { win = 0 }) + vim.api.nvim_set_option_value('foldcolumn', '1', { win = 0 }) + vim.api.nvim_set_option_value('statuscolumn', '', { win = 0 }) + vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 }) vim.cmd('silent! normal! zE') -- clear existing manual folds local line_count = vim.api.nvim_buf_line_count(bufnr) for _, range in ipairs(formatted.fold_ranges) do @@ -215,7 +222,10 @@ local function render_preview_buffer(target, formatted) end) end -function M.pick(sessions, callback) +---@param sessions Session[] +---@param callback fun(session: Session|nil) +---@param opts? { scope?: 'project' | 'global' } +function M.pick(sessions, callback, opts) local actions = { rename = { key = config.keymap.session_picker.rename_session, @@ -336,8 +346,8 @@ function M.pick(sessions, callback) format_fn = format_session_item, actions = actions, callback = callback, - title = 'Select A Session', - width = config.ui.picker_width, + title = (opts and opts.scope == 'global') and 'Select A Session (all projects)' or 'Select A Session', + width = config.ui.picker_width or 100, layout_opts = config.ui.picker, preview = 'custom', ---@param session table diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 56d1dc08..e8d89bdc 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -525,12 +525,13 @@ end ---@param sessions Session[] ---@param cb fun(session: Session|nil) -function M.select_session(sessions, cb) +---@param opts? { scope?: 'project' | 'global' } +function M.select_session(sessions, cb, opts) local session_picker = require('opencode.ui.session_picker') local util = require('opencode.util') local picker = require('opencode.ui.picker') - local success = session_picker.pick(sessions, cb) + local success = session_picker.pick(sessions, cb, opts) if not success then picker.select(sessions, { prompt = '', diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index b7a2f4dc..70cf5914 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', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, defs.session.completions) + assert.same({ 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename', 'toggle_lock' }, defs.session.completions) assert.same({ allow_empty = false }, defs.session.nested_subcommand) assert.same({ 'input', 'output' }, defs.open.completions) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index d8ff7f58..663156f7 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -9,6 +9,7 @@ local session_runtime = require('opencode.services.session_runtime') local messaging = require('opencode.services.messaging') local agent_model = require('opencode.services.agent_model') local config_file = require('opencode.config_file') +local config = require('opencode.config') local state = require('opencode.state') local store = require('opencode.state.store') local ui = require('opencode.ui.ui') @@ -805,6 +806,36 @@ describe('opencode.services.session_runtime', function() assert.truthy(state.active_session) assert.truthy(state.active_session.id) end) + + it('preserves active session when locked', function() + session_runtime.set_session_lock(true) + state.session.set_active({ id = 'locked-session' }) + + session_runtime.handle_directory_change():wait() + + assert.equal('locked-session', state.active_session.id) + assert.stub(context.unload_attachments).was_not_called() + session_runtime.set_session_lock(false) + end) + + it('toggle_session_lock overrides config.lock_session_to_directory=true', function() + local original = config.lock_session_to_directory + config.lock_session_to_directory = true + state.session.set_locked(nil) + + assert.is_true(session_runtime.is_session_locked()) + + local new_value = session_runtime.toggle_session_lock() + assert.is_false(new_value) + assert.is_false(session_runtime.is_session_locked()) + + new_value = session_runtime.toggle_session_lock() + assert.is_true(new_value) + assert.is_true(session_runtime.is_session_locked()) + + config.lock_session_to_directory = original + state.session.set_locked(nil) + end) end) describe('switch_to_mode', function()