Skip to content

Commit b977136

Browse files
authored
feat(session): cross-project session list and session lock (#403)
* fix(picker): defer fzf-lua finder for optimal list formatting * 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).
1 parent 0a9d257 commit b977136

15 files changed

Lines changed: 286 additions & 68 deletions

lua/opencode/api.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ local action_groups = {
5353
rename_session = session.rename_session,
5454
undo = session.undo,
5555
fork_session = session.fork_session,
56+
toggle_session_lock = session.toggle_session_lock,
5657
},
5758

5859
diff = {

lua/opencode/api_client.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ function OpencodeApiClient:list_sessions(directory)
195195
return self:_call('/session', 'GET', nil, { directory = directory })
196196
end
197197

198+
--- List sessions across all projects (experimental global endpoint).
199+
--- Bypasses _call's automatic directory injection so the server returns all
200+
--- directories instead of being filtered to the current cwd.
201+
--- @return Promise<GlobalSession[]>
202+
function OpencodeApiClient:list_sessions_global()
203+
if not self:_ensure_base_url() then
204+
return require('opencode.promise').new():reject('No server base url')
205+
end
206+
return server_job.call_api(self.base_url .. '/experimental/session', 'GET')
207+
end
208+
198209
--- Create a new session
199210
--- @param session_data {parentID?: string, title?: string}|nil|boolean Session creation data
200211
--- @param directory string|nil Directory path

lua/opencode/commands/handlers/session.lua

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ local M = {
1010
actions = {},
1111
}
1212

13-
local session_subcommands = { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename' }
13+
local session_subcommands = { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename', 'toggle_lock' }
1414

1515
---@param message string
1616
local function invalid_arguments(message)
@@ -94,8 +94,27 @@ function M.actions.open_input_new_session_with_title(title)
9494
end
9595

9696
---@param parent_id? string
97-
function M.actions.select_session(parent_id)
98-
session_runtime.select_session(parent_id)
97+
---@param scope? 'project' | 'global' defaults to global when session is locked, project otherwise
98+
function M.actions.select_session(parent_id, scope)
99+
if scope == nil then
100+
scope = session_runtime.is_session_locked() and 'global' or 'project'
101+
end
102+
session_runtime.select_session(parent_id, scope)
103+
end
104+
105+
---@param value? boolean if nil toggle, otherwise set to value
106+
function M.actions.toggle_session_lock(value)
107+
local new_value
108+
if value == nil then
109+
new_value = session_runtime.toggle_session_lock()
110+
else
111+
new_value = session_runtime.set_session_lock(value and true or false)
112+
end
113+
vim.notify(
114+
'Session lock ' .. (new_value and 'enabled (session preserved across cwd changes)' or 'disabled'),
115+
vim.log.levels.INFO
116+
)
117+
return new_value
99118
end
100119

101120
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
189208
return
190209
end
191210
if interaction == 'picker' then
192-
return session_runtime.select_session(direction)
211+
return session_runtime.select_session(direction, 'project')
193212
end
194213
return session_runtime.switch_session(direction)
195214
end
@@ -207,15 +226,15 @@ function M.actions.navigate_session_tree(direction, interaction, wrap, empty_pol
207226
local target_id = dir.get_target(active)
208227
if not target_id then
209228
if direction == 'sibling' then
210-
return session_runtime.select_session(nil)
229+
return session_runtime.select_session(nil, 'project')
211230
end
212231
if empty_policy == 'notify' then
213232
vim.notify('No ' .. direction, vim.log.levels.INFO)
214233
end
215234
return
216235
end
217236
if interaction == 'picker' or not dir.allow_direct then
218-
return session_runtime.select_session(target_id)
237+
return session_runtime.select_session(target_id, 'project')
219238
end
220239
return session_runtime.switch_session(target_id)
221240
end
@@ -575,11 +594,25 @@ local session_subcommand_actions = {
575594
agents_init = function()
576595
return M.actions.initialize()
577596
end,
597+
toggle_lock = function(args)
598+
local raw = args[2]
599+
local value
600+
if raw == nil or raw == '' then
601+
value = nil
602+
elseif raw == 'true' or raw == 'on' or raw == '1' then
603+
value = true
604+
elseif raw == 'false' or raw == 'off' or raw == '0' then
605+
value = false
606+
else
607+
invalid_arguments('Invalid toggle_lock argument: ' .. tostring(raw))
608+
end
609+
return M.actions.toggle_session_lock(value)
610+
end,
578611
}
579612

580613
M.command_defs = {
581614
session = {
582-
desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename)',
615+
desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename/toggle_lock)',
583616
completions = session_subcommands,
584617
nested_subcommand = { allow_empty = false },
585618
execute = function(args)
@@ -593,6 +626,12 @@ M.command_defs = {
593626
},
594627
-- action name aliases for keymap compatibility
595628
open_input_new_session = { desc = 'Open input (new session)', execute = M.actions.open_input_new_session },
629+
toggle_session_lock = {
630+
desc = 'Toggle session lock (preserve active session across cwd changes)',
631+
execute = function(args)
632+
return M.actions.toggle_session_lock(args[1])
633+
end,
634+
},
596635
select_session = {
597636
desc = 'Select session',
598637
execute = function()

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ M.defaults = {
1313
default_system_prompt = nil,
1414
keymap_prefix = '<leader>o',
1515
opencode_executable = 'opencode',
16+
lock_session_to_directory = false,
1617
server = {
1718
url = nil,
1819
port = nil,

lua/opencode/services/session_runtime.lua

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,27 @@ local agent_model = require('opencode.services.agent_model')
1313

1414
local M = {}
1515

16+
---@return boolean
17+
function M.is_session_locked()
18+
local explicit = state.store.get('session_locked')
19+
if explicit ~= nil then
20+
return explicit
21+
end
22+
return config.lock_session_to_directory == true
23+
end
24+
25+
---@param value boolean
26+
---@return boolean
27+
function M.set_session_lock(value)
28+
state.session.set_locked(value and true or false)
29+
return M.is_session_locked()
30+
end
31+
32+
---@return boolean new_value
33+
function M.toggle_session_lock()
34+
return M.set_session_lock(not M.is_session_locked())
35+
end
36+
1637
local function focus_after_session_switch(selected_session)
1738
if not state.ui.is_visible() then
1839
M.open()
@@ -34,8 +55,14 @@ local function focus_after_session_switch(selected_session)
3455
end
3556

3657
---@param parent_id string?
37-
M.select_session = Promise.async(function(parent_id)
38-
local all_sessions = session.get_all_workspace_sessions():await() or {}
58+
---@param scope? 'project' | 'global' when nil, defaults to project-scoped
59+
M.select_session = Promise.async(function(parent_id, scope)
60+
local all_sessions
61+
if scope == 'global' then
62+
all_sessions = session.get_all_global_sessions():await() or {}
63+
else
64+
all_sessions = session.get_all_workspace_sessions():await() or {}
65+
end
3966
---@cast all_sessions Session[]
4067

4168
local filtered_sessions = vim.tbl_filter(function(s)
@@ -58,7 +85,7 @@ M.select_session = Promise.async(function(parent_id)
5885
return
5986
end
6087
M.switch_session(selected_session.id)
61-
end)
88+
end, { scope = scope })
6289
end)
6390

6491
M.switch_session = Promise.async(function(session_id)
@@ -93,6 +120,9 @@ M.check_cwd = function()
93120
{ current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() }
94121
)
95122
state.context.set_current_cwd(vim.fn.getcwd())
123+
if M.is_session_locked() then
124+
return
125+
end
96126
state.session.clear_active()
97127
context.unload_attachments()
98128
end
@@ -312,7 +342,16 @@ end)
312342

313343
M.handle_directory_change = Promise.async(function()
314344
local cwd = vim.fn.getcwd()
315-
log.debug('Working directory change %s', vim.inspect({ cwd = cwd }))
345+
log.debug('Working directory change %s', vim.inspect({ cwd = cwd, locked = M.is_session_locked() }))
346+
347+
if M.is_session_locked() and state.active_session then
348+
vim.notify(
349+
'Session locked, staying on [' .. state.active_session.id .. '] in new working dir [' .. cwd .. ']',
350+
vim.log.levels.INFO
351+
)
352+
return
353+
end
354+
316355
vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO)
317356

318357
state.session.clear_active()

lua/opencode/session.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ M.get_all_workspace_sessions = Promise.async(function()
6666
return sessions
6767
end)
6868

69+
---Get all sessions across every project (no workspace filter)
70+
---@return GlobalSession[]|nil
71+
M.get_all_global_sessions = Promise.async(function()
72+
local sessions = state.api_client:list_sessions_global():await()
73+
if not sessions or type(sessions) ~= 'table' then
74+
return nil
75+
end
76+
77+
table.sort(sessions, function(a, b)
78+
return a.time.updated > b.time.updated
79+
end)
80+
81+
return sessions
82+
end)
83+
6984
---Get the most recent main workspace session
7085
---@return Session|nil
7186
M.get_last_workspace_session = Promise.async(function()

lua/opencode/state/session.lua

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,27 @@ function M.clear_active()
2222
end)
2323
end
2424

25+
---@return boolean
26+
function M.is_locked()
27+
return store.get('session_locked') == true
28+
end
29+
30+
---@param value boolean|nil nil = inherit default
31+
function M.set_locked(value)
32+
if value == nil then
33+
store.set_raw('session_locked', nil)
34+
else
35+
store.set('session_locked', value and true or false)
36+
end
37+
end
38+
39+
---@return boolean new_value
40+
function M.toggle_locked()
41+
local new_value = not M.is_locked()
42+
M.set_locked(new_value)
43+
return new_value
44+
end
45+
2546
---@param points RestorePoint[]
2647
function M.set_restore_points(points)
2748
return store.set('restore_points', points)

lua/opencode/state/store.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ local M = {}
4141
---@field required_version string
4242
---@field opencode_cli_version string|nil
4343
---@field current_cwd string|nil
44+
---@field session_locked boolean|nil
4445
---@field _hidden_buffers OpencodeHiddenBuffers|nil
4546

4647
---@type OpencodeStateData
@@ -83,6 +84,7 @@ local _state = {
8384
required_version = '0.6.3',
8485
opencode_cli_version = nil,
8586
current_cwd = vim.fn.getcwd(),
87+
session_locked = nil,
8688
_hidden_buffers = nil,
8789
}
8890

lua/opencode/types.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,18 @@
128128
---@field parentID string|nil
129129
---@field agent string|nil
130130
---@field model { id: string, providerID: string, variant?: string }|nil
131+
---@field directory? string
131132
---@field revert? SessionRevertInfo
132133
---@field share? SessionShareInfo
133134

135+
---@class SessionProjectInfo
136+
---@field id string
137+
---@field name? string
138+
---@field worktree string
139+
140+
---@class GlobalSession : Session
141+
---@field project SessionProjectInfo|nil
142+
134143
---@class OpencodeKeymapEntry
135144
---@field [1] string # Function name
136145
---@field mode? string|string[] # Mode(s) for the keymap
@@ -362,6 +371,7 @@
362371
---@field default_system_prompt string | nil
363372
---@field keymap_prefix string
364373
---@field opencode_executable 'opencode' | string -- Command run for calling opencode
374+
---@field lock_session_to_directory boolean -- If true, active session is preserved across DirChanged events
365375
---@field server OpencodeServerConfig -- Custom/external server configuration
366376
---@field keymap OpencodeKeymap
367377
---@field ui OpencodeUIConfig

0 commit comments

Comments
 (0)