Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
11 changes: 11 additions & 0 deletions lua/opencode/api_client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<GlobalSession[]>
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
Expand Down
53 changes: 46 additions & 7 deletions lua/opencode/commands/handlers/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -207,15 +226,15 @@ 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)
end
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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ M.defaults = {
default_system_prompt = nil,
keymap_prefix = '<leader>o',
opencode_executable = 'opencode',
lock_session_to_directory = false,
server = {
url = nil,
port = nil,
Expand Down
47 changes: 43 additions & 4 deletions lua/opencode/services/session_runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions lua/opencode/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions lua/opencode/state/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/state/store.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

Expand Down
10 changes: 10 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading