From 4634e7dba76f4038c3f831b74275c1e34bde02e5 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 8 Oct 2026 14:33:30 -0400 Subject: [PATCH 1/4] feat: expose a live custom slash command registry --- README.md | 22 +++++++ lua/opencode/commands/slash.lua | 55 +++++++++++----- lua/opencode/init.lua | 4 ++ lua/opencode/services/slash_registry.lua | 45 +++++++++++++ lua/opencode/ui/completion/commands.lua | 29 ++++++-- tests/unit/slash_registry_spec.lua | 84 ++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 23 deletions(-) create mode 100644 lua/opencode/services/slash_registry.lua create mode 100644 tests/unit/slash_registry_spec.lua diff --git a/README.md b/README.md index e8e42e82f..ddce933f1 100644 --- a/README.md +++ b/README.md @@ -1148,6 +1148,28 @@ See the [Opencode Skills Documentation](https://opencode.ai/docs/skills/) for ho You can run predefined user commands and built-in slash commands from the input window by typing `/`. This opens a command picker where you can select a command to execute. The output of the command will be included in your prompt context. +Local Lua commands can be registered without modifying internal tables: + +```lua +require('opencode').register_slash_command({ + name = 'worktree', + desc = 'Open a worktree session', + args = true, + fn = function(args) + -- args is a string array, empty when no arguments were supplied. + print(vim.inspect(args)) + end, +}) +``` + +Registration works before or after `setup`. Commands appear in completion and +the picker, and execute through command lifecycle hooks (filter by `/worktree`). +Names use letters, digits, underscores, or hyphens, without a leading slash. +Builtin and registered name collisions throw; local commands take precedence +over same-named server commands and skills. Use +`require('opencode').unregister_slash_command('worktree')` to remove one (returns +whether it existed). Callback errors use the normal command error handling. + **Built-in slash commands** include: - `/share` — Share the current session and get a link diff --git a/lua/opencode/commands/slash.lua b/lua/opencode/commands/slash.lua index 966186450..b10197226 100644 --- a/lua/opencode/commands/slash.lua +++ b/lua/opencode/commands/slash.lua @@ -4,6 +4,7 @@ local config_file = require('opencode.config_file') local commands = require('opencode.commands') local log = require('opencode.log') local slash_commands = require('opencode.slash_commands') +local slash_registry = require('opencode.services.slash_registry') local M = {} @@ -76,6 +77,11 @@ local function to_runtime_slash_command(slash_cmd, def) end function M.execute_builtin(slash_cmd, args) + local custom = slash_registry.list()[slash_cmd:sub(2)] + if custom then + local parsed = commands.build_parsed_intent(slash_cmd, args or {}) + return commands.execute_parsed_intent(parsed, custom.fn) + end local def = M.get_builtin_command_definitions()[slash_cmd] if not def then return @@ -96,17 +102,30 @@ M.get_commands = Promise.async(function() end local user_commands = config_file.get_user_commands():await() + local custom_commands = slash_registry.list() + for name, def in pairs(custom_commands) do + table.insert(result, { + slash_cmd = '/' .. name, + desc = def.desc, + args = def.args == true, + fn = function(args) + return M.execute_builtin('/' .. name, args) + end, + }) + end if user_commands then for name, def in pairs(user_commands) do - table.insert(result, { - slash_cmd = '/' .. name, - desc = def.description or 'User command', - fn = function(args) - local cmd_args = vim.list_extend({ name }, args or {}) - return dispatch_parsed('command', cmd_args) - end, - args = true, - }) + if not custom_commands[name] then + table.insert(result, { + slash_cmd = '/' .. name, + desc = def.description or 'User command', + fn = function(args) + local cmd_args = vim.list_extend({ name }, args or {}) + return dispatch_parsed('command', cmd_args) + end, + args = true, + }) + end end end @@ -125,14 +144,16 @@ M.get_commands = Promise.async(function() end) if ok and skills then for _, skill in ipairs(skills) do - table.insert(result, { - slash_cmd = '/' .. skill.name, - desc = skill.description or 'Skill', - fn = function(args) - return dispatch_parsed('skill', vim.list_extend({ skill.name }, args or {})) - end, - args = true, - }) + if not custom_commands[skill.name] then + table.insert(result, { + slash_cmd = '/' .. skill.name, + desc = skill.description or 'Skill', + fn = function(args) + return dispatch_parsed('skill', vim.list_extend({ skill.name }, args or {})) + end, + args = true, + }) + end end end diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 3aa4f3adb..8630b838c 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -4,6 +4,10 @@ local setup_done = false local state local session_runtime = require('opencode.services.session_runtime') +local slash_registry = require('opencode.services.slash_registry') + +M.register_slash_command = slash_registry.register +M.unregister_slash_command = slash_registry.unregister local function on_opencode_server() require('opencode.ui.permission_window').clear_all() diff --git a/lua/opencode/services/slash_registry.lua b/lua/opencode/services/slash_registry.lua new file mode 100644 index 000000000..15b703b3e --- /dev/null +++ b/lua/opencode/services/slash_registry.lua @@ -0,0 +1,45 @@ +local builtins = require('opencode.slash_commands') + +local M = {} + +---@class OpencodeCustomSlashCommand +---@field name string Name without the leading slash +---@field desc string +---@field args? boolean Whether completion leaves room for arguments +---@field fn fun(args: string[]): any + +---@type table +local registry = {} + +---Register a local command. Invalid names and builtin/registered duplicates throw. +---@param spec OpencodeCustomSlashCommand +function M.register(spec) + if not spec.name:match('^[%w_-]+$') then + error('Slash command name must contain only letters, digits, underscores, or hyphens') + end + for name in pairs(builtins.get_definitions()) do + if name == '/' .. spec.name then + error('Slash command already exists: /' .. spec.name) + end + end + if registry[spec.name] then + error('Slash command already exists: /' .. spec.name) + end + registry[spec.name] = vim.deepcopy(spec) +end + +---Remove a local command; returns false when not registered. +---@param name string +---@return boolean +function M.unregister(name) + local found = registry[name] ~= nil + registry[name] = nil + return found +end + +---@return table +function M.list() + return vim.deepcopy(registry) +end + +return M diff --git a/lua/opencode/ui/completion/commands.lua b/lua/opencode/ui/completion/commands.lua index 75ecfcbf7..b597bac35 100644 --- a/lua/opencode/ui/completion/commands.lua +++ b/lua/opencode/ui/completion/commands.lua @@ -1,6 +1,7 @@ local Promise = require('opencode.promise') local config_file = require('opencode.config_file') local slash_commands = require('opencode.slash_commands') +local slash_registry = require('opencode.services.slash_registry') local M = {} ---@param execute_slash_command? fun(slash_cmd: string, args: string[]|nil): any @@ -22,15 +23,31 @@ local function create_source(execute_slash_command) end local user_commands = config_file.get_user_commands():await() - for name, command in pairs(user_commands or {}) do + local custom_commands = slash_registry.list() + for name, command in pairs(custom_commands) do + local key = '/' .. name table.insert(results, { - name = '/' .. name, - description = command.description or 'User command', - documentation = 'Opencode command: /' .. name, - command_key = name, - args = true, + name = key, + description = command.desc, + documentation = 'Opencode command: ' .. key, + command_key = key, + args = command.args == true, + fn = execute_slash_command and function(args) + return execute_slash_command(key, args) + end, }) end + for name, command in pairs(user_commands or {}) do + if not custom_commands[name] then + table.insert(results, { + name = '/' .. name, + description = command.description or 'User command', + documentation = 'Opencode command: /' .. name, + command_key = name, + args = true, + }) + end + end return results end) diff --git a/tests/unit/slash_registry_spec.lua b/tests/unit/slash_registry_spec.lua new file mode 100644 index 000000000..a879dc2aa --- /dev/null +++ b/tests/unit/slash_registry_spec.lua @@ -0,0 +1,84 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local Promise = require('opencode.promise') +local registry = require('opencode.services.slash_registry') +local slash = require('opencode.commands.slash') +local dispatch = require('opencode.commands.dispatch') + +describe('custom slash registry', function() + local user_commands + + before_each(function() + user_commands = stub(require('opencode.config_file'), 'get_user_commands').returns(Promise.new():resolve({})) + end) + + after_each(function() + registry.unregister('worktree') + user_commands:revert() + dispatch.reset_hooks_for_test() + end) + + it('rejects invalid names and builtin or registered collisions', function() + local function register(name) + registry.register({ name = name, desc = 'test', fn = function() end }) + end + assert.has_error(function() + register('/worktree') + end) + assert.has_error(function() + register('help') + end) + register('worktree') + assert.has_error(function() + register('worktree') + end) + assert.is_true(registry.unregister('worktree')) + assert.is_false(registry.unregister('worktree')) + end) + + it('registers after slash module loading and uses dispatch hooks and arguments', function() + local received, hook_name + registry.register({ + name = 'worktree', + desc = 'Worktree', + args = true, + fn = function(args) + received = args + return 'done' + end, + }) + dispatch.register_hook('before', function(ctx) + hook_name = ctx.intent.name + end, { command = '/worktree' }) + local command = slash.resolve_input('/worktree feature') + assert.equal('done', command.fn({ 'feature' })) + assert.same({ 'feature' }, received) + assert.equal('/worktree', hook_name) + slash.execute_builtin('/worktree') + assert.same({}, received) + end) + + it('shares live registrations with completion and unregisters them', function() + registry.register({ name = 'worktree', desc = 'Worktree', args = true, fn = function() end }) + local source = require('opencode.ui.completion.commands').get_source(slash.execute_builtin) + local context = { line = '/worktree', trigger_char = '/', input = 'worktree' } + local items = source.complete(context):wait() + assert.equal(1, #items) + assert.equal('worktree ', items[1].insert_text) + registry.unregister('worktree') + assert.same({}, source.complete(context):wait()) + end) + + it('prefers local callbacks over server commands of the same name', function() + user_commands:revert() + user_commands = stub(require('opencode.config_file'), 'get_user_commands').returns( + Promise.new():resolve({ worktree = { description = 'Server command' } }) + ) + registry.register({ name = 'worktree', desc = 'Local command', fn = function() end }) + local matches = vim.tbl_filter(function(command) + return command.slash_cmd == '/worktree' + end, slash.get_commands():wait()) + assert.equal(1, #matches) + assert.equal('Local command', matches[1].desc) + end) +end) From ff9b3d3224c2b7e7c954c0c20e8795394d4b0952 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 8 Oct 2026 14:38:40 -0400 Subject: [PATCH 2/4] feat: open directory-bound sessions through the public API --- README.md | 20 ++++ lua/opencode/api.lua | 1 + lua/opencode/commands/handlers/session.lua | 2 + lua/opencode/services/session_runtime.lua | 97 +++++++++++++++- lua/opencode/state/session_tabs.lua | 1 + lua/opencode/ui/autocmds.lua | 3 +- tests/unit/directory_session_spec.lua | 123 +++++++++++++++++++++ 7 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 tests/unit/directory_session_spec.lua diff --git a/README.md b/README.md index ddce933f1..3647e2a4a 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,26 @@ Available icon keys (see implementation at lua/opencode/ui/icons.lua lines 7-29) - `persist_state = true` (default): `toggle()` hides/restores the UI and keeps buffers/session view in memory for fast restore. - `persist_state = false`: `toggle()` fully tears down UI buffers and recreates them on next open. +Directory-bound sessions can be opened in logical panel tabs without changing +Neovim's cwd: + +```lua +require('opencode.api').open_session({ + directory = '/path/to/repo/.worktrees/feature', + new = true, + title = 'Feature work', +}) +``` + +Returns a Promise resolving to the session; failures reject. The directory must +exist locally (normal path mapping still applies). Omit `new` to reopen the most +recent root session created in that exact directory, or create one if absent. +Pass `session_id` to open a specific session; `new` and `session_id` are mutually +exclusive. Already-open sessions reuse their logical tab. Completion and new +sessions use the bound directory, including after tab switches. Explicit +directory changes still follow `lock_session_to_directory`; locked bound tabs +keep their directory. + Related APIs: - `require('opencode.api').toggle()` follows the `persist_state` behavior above. diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 1f3d92214..b6498af7e 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -40,6 +40,7 @@ local action_groups = { }, session = { + open_session = session.open_session, open_input_new_session = session.open_input_new_session, navigate_session_tree = session.navigate_session_tree, share = session.share, diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index bb259768c..1bce5fca3 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -116,6 +116,8 @@ local function run_api_action_with_checktime(request_promise, error_prefix, on_s end) end +M.actions.open_session = session_runtime.open_session + function M.actions.open_input_new_session() return session_runtime.open({ new_session = true, focus = 'input', start_insert = true }) end diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 18508829b..c395ab09d 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -281,6 +281,11 @@ M.is_prompting_allowed = function() end M.check_cwd = function() + local runtime = session_tabs.current() + if runtime and runtime.bound_directory then + state.context.set_current_cwd(runtime.bound_directory) + return + end if state.current_cwd ~= vim.fn.getcwd() then log.debug( 'CWD changed since last check, resetting session and context', @@ -533,14 +538,22 @@ end) ---Mount an existing session in a new logical panel tab. ---@param selected_session OpencodeSession +---@param directory? string Explicit tab directory; omitted preserves existing behavior. ---@return Promise -M.open_session_in_tab = Promise.async(function(selected_session) +M.open_session_in_tab = Promise.async(function(selected_session, directory) if not selected_session or not selected_session.id then return nil end for _, runtime in ipairs(session_tabs.list()) do if runtime.active_session and runtime.active_session.id == selected_session.id then + if directory then + runtime.bound_directory = directory + runtime.current_cwd = directory + if session_tabs.active_id() == runtime.id then + state.context.set_current_cwd(directory) + end + end M.switch_session_tab(runtime.id):await() return selected_session end @@ -554,6 +567,10 @@ M.open_session_in_tab = Promise.async(function(selected_session) session_tabs.sync() local runtime = session_tabs.create(selected_session) + if directory then + runtime.bound_directory = directory + runtime.current_cwd = directory + end session_tabs.activate(runtime) context.restore(session_tabs.get_context()) state.model.clear() @@ -568,6 +585,66 @@ M.open_session_in_tab = Promise.async(function(selected_session) return selected_session end) +---@class OpencodeOpenSessionOpts +---@field directory string Existing local directory (relative paths resolve against Neovim cwd) +---@field new? boolean Always create a session; incompatible with session_id +---@field session_id? string Open this session in the supplied directory +---@field title? string Title for a newly created session + +---Open a directory-bound logical tab without changing Neovim cwd. +---Defaults to the latest root session in that directory, creating one if absent. +---Rejects on invalid options, startup, lookup, creation, or panel failure. +---@param opts OpencodeOpenSessionOpts +---@return Promise +M.open_session = Promise.async(function(opts) + if opts.new and opts.session_id then + error('new and session_id are mutually exclusive') + end + if opts.directory == '' then + error('Session directory cannot be empty') + end + local directory = vim.fs.normalize(vim.fn.fnamemodify(opts.directory, ':p')) + if vim.fn.isdirectory(directory) ~= 1 then + error('Session directory does not exist: ' .. directory) + end + local location = { directory = directory } + local connection = server_job.ensure_server():await() + local selected_session + if opts.session_id then + selected_session = connection.operations + .get_session(connection, opts.session_id, location, util.apply_path_map, util.apply_reverse_path_map) + :await() + if not selected_session then + error('Session not found: ' .. opts.session_id) + end + local selected_directory = session_directory(selected_session) + if selected_directory and vim.fs.normalize(selected_directory) ~= directory then + error('Session belongs to a different directory: ' .. selected_directory) + end + elseif not opts.new then + local sessions = connection.operations + .list_sessions_project(connection, location, util.apply_path_map, util.apply_reverse_path_map) + :await() + for _, candidate in ipairs(sort_sessions(sessions)) do + if not candidate.parentID and session_directory(candidate) == directory then + selected_session = candidate + break + end + end + end + if not selected_session then + selected_session = connection.operations + .create_session(connection, location, { title = opts.title }, util.apply_path_map, util.apply_reverse_path_map) + :await() + if not selected_session then + error('Failed to create session') + end + end + selected_session = vim.tbl_extend('force', {}, selected_session, { location = location }) + M.open_session_in_tab(selected_session, directory):await() + return selected_session +end) + ---@param session_id string ---@return Promise M.open_session_in_tab_by_id = Promise.async(function(session_id) @@ -585,11 +662,13 @@ end) ---@param title? string ---@return Promise M.open_session_tab = Promise.async(function(title) + local runtime = session_tabs.current() + local directory = runtime and runtime.bound_directory local new_session = M.create_new_session(title):await() if not new_session then return nil end - return M.open_session_in_tab(new_session):await() + return M.open_session_in_tab(new_session, directory):await() end) ---Switch to a logical tab inside the Opencode panel. @@ -898,11 +977,16 @@ M._on_current_permission_change = Promise.async(function(_, new, old) end end) -M.handle_directory_change = Promise.async(function() - local cwd = vim.fn.getcwd() +---@param cwd? string Directory from DirChanged; defaults to Neovim cwd. +M.handle_directory_change = Promise.async(function(cwd) + cwd = cwd or vim.fn.getcwd() 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 + local runtime = session_tabs.current() + if not (runtime and runtime.bound_directory) then + state.context.set_current_cwd(cwd) + end vim.notify( 'Session locked, staying on [' .. state.active_session.id .. '] in new working dir [' .. cwd .. ']', vim.log.levels.INFO @@ -912,6 +996,11 @@ M.handle_directory_change = Promise.async(function() vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) + local runtime = session_tabs.current() + if runtime then + runtime.bound_directory = nil + end + state.context.set_current_cwd(cwd) state.session.clear_active() context.unload_attachments() diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index 6a98a13fc..a0ba2d77c 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -37,6 +37,7 @@ local renderer_context = require('opencode.ui.renderer.ctx') ---@field pre_zoom_width integer|nil ---@field last_window_width_ratio number|nil ---@field current_cwd string|nil +---@field bound_directory? string Explicit directory owned by this logical tab ---@field session_locked boolean|nil ---@field _hidden_buffers OpencodeHiddenBuffers|nil ---@field context_data OpencodeContext|nil diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index a989cea21..56db7fe61 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -238,8 +238,7 @@ function M.setup_autocmds(windows) end end - state.context.set_current_cwd(event.file) - require('opencode.services.session_runtime').handle_directory_change() + require('opencode.services.session_runtime').handle_directory_change(event.file) end, }) diff --git a/tests/unit/directory_session_spec.lua b/tests/unit/directory_session_spec.lua new file mode 100644 index 000000000..cd0726b80 --- /dev/null +++ b/tests/unit/directory_session_spec.lua @@ -0,0 +1,123 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local Promise = require('opencode.promise') +local state = require('opencode.state') +local runtime = require('opencode.services.session_runtime') +local support = require('tests.unit.services_spec_support') +local config = require('opencode.config') + +describe('directory-bound session API', function() + local saved, open, ensure, connection, directory, original_lock + + before_each(function() + saved = vim.deepcopy(state.store.state()) + original_lock = config.lock_session_to_directory + config.lock_session_to_directory = false + state.session_tabs.reset() + state.ui.set_windows(nil) + state.session.clear_active() + state.session.set_locked(false) + directory = vim.fs.normalize(vim.fn.getcwd() .. '/tests') + connection = support.mock_connection() + ensure = stub(require('opencode.server_job'), 'ensure_server').returns(Promise.new():resolve(connection)) + open = stub(runtime, 'open').invokes(function() + runtime.check_cwd() + return Promise.new():resolve('ok') + end) + connection.operations.create_session = function(_, location, input) + return Promise.new():resolve({ + id = input.title or 'new', + title = 'Session', + location = location, + time = { updated = 1 }, + }) + end + end) + + after_each(function() + open:revert() + ensure:revert() + config.lock_session_to_directory = original_lock + state.session_tabs.reset() + for key in pairs(state.store.state()) do + state.store.set_raw(key, nil) + end + for key, value in pairs(saved) do + state.store.set_raw(key, value) + end + end) + + it('creates in the requested directory without changing Neovim cwd', function() + local cwd = vim.fn.getcwd() + local result = require('opencode.api').open_session({ directory = directory, new = true, title = 'work' }):wait() + assert.equal('work', result.id) + assert.same({ directory = directory }, result.location) + assert.equal(directory, state.current_cwd) + assert.equal(cwd, vim.fn.getcwd()) + assert.equal(directory, state.session_tabs.current().bound_directory) + end) + + it('restores each tab directory and creates subsequent sessions there', function() + runtime.open_session({ directory = directory, new = true, title = 'first' }):wait() + local first = state.session_tabs.current() + runtime.open_session({ directory = vim.fn.getcwd(), new = true, title = 'second' }):wait() + runtime.switch_session_tab(first.id):wait() + assert.equal(directory, state.current_cwd) + local next_session = runtime.create_new_session('next'):wait() + assert.equal(directory, next_session.location.directory) + runtime.open_session_tab('third'):wait() + assert.equal(directory, state.current_cwd) + assert.equal(directory, state.session_tabs.current().bound_directory) + end) + + it('reopens only the latest root session in the exact directory', function() + connection.operations.list_sessions_project = function(_, location) + assert.equal(directory, location.directory) + return Promise.new():resolve({ + { id = 'other', directory = vim.fn.getcwd(), time = { updated = 5 } }, + { id = 'child', directory = directory, parentID = 'root', time = { updated = 4 } }, + { id = 'latest', directory = directory, time = { updated = 3 } }, + { id = 'older', directory = directory, time = { updated = 2 } }, + }) + end + assert.equal('latest', runtime.open_session({ directory = directory }):wait().id) + assert.equal(1, #state.session_tabs.list()) + runtime.open_session({ directory = directory, session_id = 'latest' }):wait() + assert.equal(1, #state.session_tabs.list()) + end) + + it('creates when no root session exists in the directory', function() + assert.equal('new', runtime.open_session({ directory = directory }):wait().id) + end) + + it('rejects invalid options and creation errors before activating a tab', function() + for _, opts in ipairs({ + { directory = directory, new = true, session_id = 'id' }, + { directory = '' }, + { directory = directory .. '/nonexistent' }, + }) do + assert.is_false(pcall(function() + runtime.open_session(opts):wait() + end)) + end + connection.operations.create_session = function() + return Promise.new():reject('create failed') + end + assert.is_false(pcall(function() + runtime.open_session({ directory = directory, new = true }):wait() + end)) + assert.is_nil(state.active_session) + assert.equal(0, #state.session_tabs.list()) + end) + + it('preserves a locked binding but releases it on unlocked DirChanged', function() + runtime.open_session({ directory = directory, new = true }):wait() + runtime.set_session_lock(true) + runtime.handle_directory_change(vim.fn.getcwd()):wait() + assert.equal(directory, state.current_cwd) + runtime.set_session_lock(false) + runtime.handle_directory_change(vim.fn.getcwd()):wait() + assert.is_nil(state.session_tabs.current().bound_directory) + assert.equal(vim.fn.getcwd(), state.current_cwd) + end) +end) From 3391e6b965261f8acf0afd347370b888f65369dc Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 8 Oct 2026 15:13:41 -0400 Subject: [PATCH 3/4] feat(session): support per-directory lock policy function Allow lock_session_to_directory to be a function called on each directory change with { from, to, session }. Manual lock still overrides the policy, and errors are reported and fall back to following the cwd. Track bound_editor_cwd on session tabs so a bound directory is distinguished from an actual editor directory change in check_cwd and handle_directory_change. --- README.md | 24 +++ lua/opencode/services/session_runtime.lua | 59 +++++++- lua/opencode/state/session_tabs.lua | 1 + lua/opencode/types.lua | 7 +- tests/unit/lock_policy_spec.lua | 172 ++++++++++++++++++++++ 5 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 tests/unit/lock_policy_spec.lua diff --git a/README.md b/README.md index 3647e2a4a..3580f3f18 100644 --- a/README.md +++ b/README.md @@ -590,6 +590,30 @@ sessions use the bound directory, including after tab switches. Explicit directory changes still follow `lock_session_to_directory`; locked bound tabs keep their directory. +For worktree or other per-project workflows, `lock_session_to_directory` also +accepts a function. It is called on each directory change with +`{ from, to, session }` and returns `true` to keep the active session or `false` +to load the target directory's last session. `from` is the bound directory (or +the directory the session was loaded from) and `to` is the new directory. An +error in the function is reported and falls back to following the cwd. Manual +lock toggles still override the policy per logical tab. + +```lua +require('opencode').setup({ + lock_session_to_directory = function(change) + local function common_dir(dir) + local r = vim.system({ 'git', '-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir' }, { text = true }):wait() + return r.code == 0 and vim.trim(r.stdout) or nil + end + local repo = common_dir(change.from) + return repo ~= nil and repo == common_dir(change.to) + end, +}) +``` + +That example keeps a session across the main checkout and linked worktrees of +the same repository. + Related APIs: - `require('opencode.api').toggle()` follows the `persist_state` behavior above. diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index c395ab09d..7c08d8702 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -159,13 +159,40 @@ local function sort_sessions(sessions) return sessions end +---Manual lock wins over config. A function policy decides per directory change; +---without `cwd` it is evaluated against the current Neovim cwd. +---@param cwd? string Directory the editor is moving to ---@return boolean -function M.is_session_locked() +function M.is_session_locked(cwd) local explicit = state.store.get('session_locked') if explicit ~= nil then return explicit end - return config.lock_session_to_directory == true + local policy = config.lock_session_to_directory + if type(policy) ~= 'function' then + return policy == true + end + local session = state.active_session + if not session then + return false + end + local runtime = session_tabs.current() + local from = (runtime and runtime.bound_directory) + or (session.location and session.location.directory) + or state.current_cwd + if not from then + return false + end + local ok, result = pcall(policy, { + from = from, + to = cwd or vim.fn.getcwd(), + session = session, + }) + if not ok then + vim.notify('lock_session_to_directory failed: ' .. tostring(result), vim.log.levels.WARN) + return false + end + return result == true end ---@param value boolean @@ -283,16 +310,23 @@ end M.check_cwd = function() local runtime = session_tabs.current() if runtime and runtime.bound_directory then - state.context.set_current_cwd(runtime.bound_directory) - return + local cwd = vim.fn.getcwd() + if runtime.bound_editor_cwd == cwd or M.is_session_locked(cwd) then + runtime.bound_editor_cwd = cwd + state.context.set_current_cwd(runtime.bound_directory) + return + end + runtime.bound_directory = nil + runtime.bound_editor_cwd = nil end if state.current_cwd ~= vim.fn.getcwd() then log.debug( 'CWD changed since last check, resetting session and context', { current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() } ) + local locked = M.is_session_locked(vim.fn.getcwd()) state.context.set_current_cwd(vim.fn.getcwd()) - if M.is_session_locked() then + if locked then return end state.session.clear_active() @@ -549,6 +583,7 @@ M.open_session_in_tab = Promise.async(function(selected_session, directory) if runtime.active_session and runtime.active_session.id == selected_session.id then if directory then runtime.bound_directory = directory + runtime.bound_editor_cwd = vim.fn.getcwd() runtime.current_cwd = directory if session_tabs.active_id() == runtime.id then state.context.set_current_cwd(directory) @@ -569,6 +604,7 @@ M.open_session_in_tab = Promise.async(function(selected_session, directory) local runtime = session_tabs.create(selected_session) if directory then runtime.bound_directory = directory + runtime.bound_editor_cwd = vim.fn.getcwd() runtime.current_cwd = directory end session_tabs.activate(runtime) @@ -693,6 +729,9 @@ M.switch_session_tab = Promise.async(function(tab_id) session_tabs.activate(runtime) context.restore(session_tabs.get_context()) + if runtime.bound_directory then + runtime.bound_editor_cwd = vim.fn.getcwd() + end local focus = state.last_focused_opencode_window == 'output' and 'output' or 'input' M.open({ focus = focus, @@ -980,11 +1019,14 @@ end) ---@param cwd? string Directory from DirChanged; defaults to Neovim cwd. M.handle_directory_change = Promise.async(function(cwd) cwd = cwd or vim.fn.getcwd() - log.debug('Working directory change %s', vim.inspect({ cwd = cwd, locked = M.is_session_locked() })) + local locked = M.is_session_locked(cwd) + log.debug('Working directory change %s', vim.inspect({ cwd = cwd, locked = locked })) - if M.is_session_locked() and state.active_session then + if locked and state.active_session then local runtime = session_tabs.current() - if not (runtime and runtime.bound_directory) then + if runtime and runtime.bound_directory then + runtime.bound_editor_cwd = cwd + else state.context.set_current_cwd(cwd) end vim.notify( @@ -999,6 +1041,7 @@ M.handle_directory_change = Promise.async(function(cwd) local runtime = session_tabs.current() if runtime then runtime.bound_directory = nil + runtime.bound_editor_cwd = nil end state.context.set_current_cwd(cwd) state.session.clear_active() diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index a0ba2d77c..c6395be6f 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -38,6 +38,7 @@ local renderer_context = require('opencode.ui.renderer.ctx') ---@field last_window_width_ratio number|nil ---@field current_cwd string|nil ---@field bound_directory? string Explicit directory owned by this logical tab +---@field bound_editor_cwd? string Last observed Neovim cwd; distinguishes a binding from an actual directory change ---@field session_locked boolean|nil ---@field _hidden_buffers OpencodeHiddenBuffers|nil ---@field context_data OpencodeContext|nil diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index c3d20c3cc..5c12910b7 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -433,6 +433,11 @@ ---@field preset_args? string[] ---@field fn? fun(args:string[]|nil):nil|Promise|any +---@class OpencodeDirectoryChange +---@field from string Directory the session is bound to (or was loaded from) +---@field to string Directory the editor is moving to +---@field session OpencodeSession The active session + ---@class OpencodeConfig ---@field preferred_picker 'telescope' | 'telescope.nvim' | 'fzf' | 'fzf-lua' | 'mini.pick' | 'snacks' | 'snacks.nvim' | 'select' | nil ---@field default_global_keymaps boolean @@ -440,7 +445,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 lock_session_to_directory boolean|fun(change: OpencodeDirectoryChange): boolean -- true locks everywhere; a function decides per directory change; errors fall back to following cwd ---@field server OpencodeServerConfig -- Custom/external server configuration ---@field keymap OpencodeKeymap ---@field ui OpencodeUIConfig diff --git a/tests/unit/lock_policy_spec.lua b/tests/unit/lock_policy_spec.lua new file mode 100644 index 000000000..ee10a0ced --- /dev/null +++ b/tests/unit/lock_policy_spec.lua @@ -0,0 +1,172 @@ +local assert = require('luassert') +local stub = require('luassert.stub') +local Promise = require('opencode.promise') +local state = require('opencode.state') +local config = require('opencode.config') +local runtime = require('opencode.services.session_runtime') +local support = require('tests.unit.services_spec_support') + +describe('session lock policy', function() + local base, project, other, saved, previous_policy, ensure, open, notify + local seen + + before_each(function() + base = '/tmp/opencode/lock-policy-' .. tostring(vim.uv.hrtime()) + project, other = base .. '/project', base .. '/other' + vim.fn.mkdir(project, 'p') + vim.fn.mkdir(other, 'p') + saved = vim.deepcopy(state.store.state()) + previous_policy = config.lock_session_to_directory + seen = {} + state.session_tabs.reset() + state.ui.set_windows(nil) + state.session.set_locked(nil) + state.context.set_current_cwd(project) + state.session.set_active({ id = 'work', location = { directory = project } }) + state.session_tabs.ensure_current() + local connection = support.mock_connection() + connection.operations.list_sessions_project = function(_, location) + return Promise.new():resolve({ { id = 'other', location = location, time = { updated = 2 } } }) + end + ensure = stub(require('opencode.server_job'), 'ensure_server').returns(Promise.new():resolve(connection)) + open = stub(runtime, 'open').invokes(function() + runtime.check_cwd() + return Promise.new():resolve('ok') + end) + notify = stub(vim, 'notify') + end) + + after_each(function() + ensure:revert() + open:revert() + notify:revert() + config.lock_session_to_directory = previous_policy + state.session_tabs.reset() + vim.fn.delete(base, 'rf') + for key in pairs(state.store.state()) do + state.store.set_raw(key, nil) + end + for key, value in pairs(saved) do + state.store.set_raw(key, value) + end + end) + + it('passes from, to, and session to the function', function() + config.lock_session_to_directory = function(change) + table.insert(seen, change) + return true + end + assert.is_true(runtime.is_session_locked(other)) + assert.equal(project, seen[1].from) + assert.equal(other, seen[1].to) + assert.equal('work', seen[1].session.id) + end) + + it('keeps the session when the function returns true', function() + config.lock_session_to_directory = function() + return true + end + runtime.handle_directory_change(other):wait() + assert.equal('work', state.active_session.id) + assert.equal(other, state.current_cwd) + end) + + it('loads the target when the function returns false', function() + config.lock_session_to_directory = function() + return false + end + runtime.handle_directory_change(other):wait() + assert.equal('other', state.active_session.id) + assert.equal(other, state.current_cwd) + end) + + it('follows the cwd when the function errors and reports it', function() + config.lock_session_to_directory = function() + error('boom') + end + assert.is_false(runtime.is_session_locked(other)) + assert.stub(notify).was_called() + end) + + it('uses the bound directory as from', function() + runtime.open_session({ directory = project, session_id = 'bound' }):wait() + config.lock_session_to_directory = function(change) + table.insert(seen, change) + return false + end + runtime.is_session_locked(other) + assert.equal(project, seen[1].from) + end) + + it('applies the manual lock override before the function', function() + config.lock_session_to_directory = function() + return false + end + assert.is_true(runtime.toggle_session_lock()) + assert.is_true(runtime.is_session_locked(other)) + assert.is_false(runtime.set_session_lock(false)) + assert.is_false(runtime.is_session_locked(other)) + end) + + it('returns false without an active session', function() + config.lock_session_to_directory = function() + return true + end + state.session.clear_active() + assert.is_false(runtime.is_session_locked(other)) + end) + + it('keeps a bound tab in its directory while the policy locks it', function() + runtime.open_session({ directory = project, session_id = 'bound' }):wait() + config.lock_session_to_directory = function() + return true + end + local getcwd = stub(vim.fn, 'getcwd').returns(other) + local ok, err = pcall(function() + runtime.check_cwd() + assert.equal(project, state.current_cwd) + assert.equal('bound', state.active_session.id) + end) + getcwd:revert() + assert.is_true(ok, tostring(err)) + end) + + it('supports a per-repository policy written against git', function() + local repo, worktree = base .. '/repo', base .. '/repo-linked' + vim.fn.mkdir(repo, 'p') + local function git(directory, ...) + local argv = vim.list_extend({ 'git', '-C', directory }, { ... }) + local result = vim.system(argv, { text = true }):wait() + assert.equal(0, result.code, result.stderr) + end + git(repo, 'init', '--quiet') + git( + repo, + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--quiet', + '--allow-empty', + '-m', + 'Initial' + ) + git(repo, 'worktree', 'add', '--quiet', '--detach', worktree) + + config.lock_session_to_directory = function(change) + local function common_dir(dir) + local r = vim + .system({ 'git', '-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir' }, { text = true }) + :wait() + return r.code == 0 and vim.trim(r.stdout) or nil + end + local source = common_dir(change.from) + return source ~= nil and source == common_dir(change.to) + end + + state.session.set_active({ id = 'work', location = { directory = repo } }) + assert.is_true(runtime.is_session_locked(worktree)) + assert.is_false(runtime.is_session_locked(other)) + end) +end) From 1b38bf37bcc49a9f09fdf20c6d803c78d8a19ef9 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Sat, 10 Oct 2026 11:48:53 -0400 Subject: [PATCH 4/4] fix(session): match mapped directories when resolving sessions --- lua/opencode/services/session_runtime.lua | 13 ++++- tests/unit/directory_session_spec.lua | 70 ++++++++++++++++++++++- tests/unit/lock_policy_spec.lua | 2 +- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 7c08d8702..9255841ae 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -645,6 +645,14 @@ M.open_session = Promise.async(function(opts) end local location = { directory = directory } local connection = server_job.ensure_server():await() + local server_directory = vim.fs.normalize(util.apply_path_map(directory)) + ---@param candidate_directory string + ---@return boolean + local function matches_directory(candidate_directory) + local normalized = vim.fs.normalize(candidate_directory) + -- Responses may remain server-side when no reverse mapping is configured. + return normalized == directory or normalized == server_directory + end local selected_session if opts.session_id then selected_session = connection.operations @@ -654,7 +662,7 @@ M.open_session = Promise.async(function(opts) error('Session not found: ' .. opts.session_id) end local selected_directory = session_directory(selected_session) - if selected_directory and vim.fs.normalize(selected_directory) ~= directory then + if selected_directory and not matches_directory(selected_directory) then error('Session belongs to a different directory: ' .. selected_directory) end elseif not opts.new then @@ -662,7 +670,8 @@ M.open_session = Promise.async(function(opts) .list_sessions_project(connection, location, util.apply_path_map, util.apply_reverse_path_map) :await() for _, candidate in ipairs(sort_sessions(sessions)) do - if not candidate.parentID and session_directory(candidate) == directory then + local candidate_directory = session_directory(candidate) + if not candidate.parentID and candidate_directory and matches_directory(candidate_directory) then selected_session = candidate break end diff --git a/tests/unit/directory_session_spec.lua b/tests/unit/directory_session_spec.lua index cd0726b80..cfa795eef 100644 --- a/tests/unit/directory_session_spec.lua +++ b/tests/unit/directory_session_spec.lua @@ -7,11 +7,15 @@ local support = require('tests.unit.services_spec_support') local config = require('opencode.config') describe('directory-bound session API', function() - local saved, open, ensure, connection, directory, original_lock + local saved, open, ensure, connection, directory, original_lock, original_path_map, original_reverse_path_map before_each(function() saved = vim.deepcopy(state.store.state()) original_lock = config.lock_session_to_directory + original_path_map = config.server.path_map + original_reverse_path_map = config.server.reverse_path_map + config.server.path_map = nil + config.server.reverse_path_map = nil config.lock_session_to_directory = false state.session_tabs.reset() state.ui.set_windows(nil) @@ -38,6 +42,8 @@ describe('directory-bound session API', function() open:revert() ensure:revert() config.lock_session_to_directory = original_lock + config.server.path_map = original_path_map + config.server.reverse_path_map = original_reverse_path_map state.session_tabs.reset() for key in pairs(state.store.state()) do state.store.set_raw(key, nil) @@ -90,6 +96,68 @@ describe('directory-bound session API', function() assert.equal('new', runtime.open_session({ directory = directory }):wait().id) end) + it('reuses mapped server sessions without a reverse path map', function() + local server_directory = '/server/worktree' + config.server.path_map = function(path) + return path == directory and server_directory or path + end + connection.operations.list_sessions_project = function(_, location, path_map) + assert.equal(server_directory, path_map(location.directory)) + return Promise.new():resolve({ + { id = 'other', directory = '/server/other', time = { updated = 5 } }, + { id = 'child', directory = server_directory, parentID = 'root', time = { updated = 4 } }, + { id = 'latest', directory = server_directory .. '/.', time = { updated = 3 } }, + { id = 'older', location = { directory = server_directory }, time = { updated = 2 } }, + }) + end + local create = stub(connection.operations, 'create_session') + local ok, err = pcall(function() + local result = runtime.open_session({ directory = directory }):wait() + assert.equal('latest', result.id) + assert.equal(directory, result.location.directory) + runtime.open_session({ directory = directory }):wait() + assert.equal(1, #state.session_tabs.list()) + assert.stub(create).was_not_called() + end) + create:revert() + assert.is_true(ok, tostring(err)) + end) + + it('accepts mapped explicit sessions but rejects another server directory', function() + local server_directory = '/server/worktree' + config.server.path_map = function(path) + return path == directory and server_directory or path + end + connection.operations.get_session = function(_, id) + return Promise.new():resolve({ id = id, directory = server_directory .. '/.', time = { updated = 1 } }) + end + assert.equal('mapped', runtime.open_session({ directory = directory, session_id = 'mapped' }):wait().id) + server_directory = '/server/other' + config.server.path_map = function(path) + return path == directory and '/server/worktree' or path + end + assert.is_false(pcall(function() + runtime.open_session({ directory = directory, session_id = 'other' }):wait() + end)) + assert.equal(1, #state.session_tabs.list()) + end) + + it('reuses normalized local sessions returned by a reverse path map', function() + config.server.path_map = function() + return '/server/worktree' + end + config.server.reverse_path_map = function() + return directory .. '/.' + end + connection.operations.list_sessions_project = function(_, _, _, reverse_path_map) + return Promise.new():resolve({ + { id = 'local', location = { directory = reverse_path_map('/server/worktree') }, time = { updated = 1 } }, + }) + end + assert.equal('local', runtime.open_session({ directory = directory }):wait().id) + assert.equal(directory, state.current_cwd) + end) + it('rejects invalid options and creation errors before activating a tab', function() for _, opts in ipairs({ { directory = directory, new = true, session_id = 'id' }, diff --git a/tests/unit/lock_policy_spec.lua b/tests/unit/lock_policy_spec.lua index ee10a0ced..3943f7fb2 100644 --- a/tests/unit/lock_policy_spec.lua +++ b/tests/unit/lock_policy_spec.lua @@ -11,7 +11,7 @@ describe('session lock policy', function() local seen before_each(function() - base = '/tmp/opencode/lock-policy-' .. tostring(vim.uv.hrtime()) + base = vim.fs.normalize(vim.fn.fnamemodify(vim.fn.tempname(), ':p')) project, other = base .. '/project', base .. '/other' vim.fn.mkdir(project, 'p') vim.fn.mkdir(other, 'p')