Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ require('opencode').setup({
notify_on_background_prompt = true,
window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder',
persist_state = true,
Comment thread
sudo-tee marked this conversation as resolved.
restore_tabs_on_startup = true, -- Keep project-scoped tab groups and restore them across Neovim restarts
icons = {
preset = 'nerdfonts',
overrides = {},
Expand Down Expand Up @@ -497,6 +498,7 @@ the everyday ones. Session diff remapping has its own [example](review.md#custom
| `ui.window_width` | `0.40` | Split width as a fraction of editor width |
| `ui.zoom_width` | `0.8` | Zoomed split width |
| `ui.persist_state` | `true` | Preserve UI buffers when closing/hiding |
| `ui.restore_tabs_on_startup` | `true` | Keep and restore project-scoped panel tab groups |
| `ui.input.min_height` / `max_height` | `0.10` / `0.25` | Input height bounds as window-height fractions |
| `ui.input.text.wrap` | `false` | Wrap prompt text |
| `ui.input.auto_hide` | `false` | Hide input after submission or focus moves to output |
Expand Down
21 changes: 21 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,27 @@ The tab strip is hidden while there is only one tab; set
asks a question or needs a permission, you get a notification. Turn that off
with `ui.notify_on_background_prompt = false`.

Panel tabs belong to an OpenCode project and configured server. Subdirectories
and worktrees with the same project ID share a tab group. Changing projects
with `:cd` saves the outgoing group and switches the entire panel to the new
project's group; a project without saved tabs starts empty. A locked session
pins the current group until unlocked. Non-project directories use separate
directory-scoped groups.

Groups are saved on project switches and when Neovim exits, and restored on
first use. Switching back within Neovim resumes the group's buffers and drafts.
Their order, active tab, directory bindings, and session locks are restored;
drafts and window state are not. Deleted sessions and missing directories are
skipped. If the first open starts an explicit new session, saved tabs are
restored behind it and the new session stays active.

Set `ui.restore_tabs_on_startup = false` to disable project groups and persistence,
and retain the previous cwd-based current-session selection behavior.
This is separate from `ui.persist_state`, which keeps buffers when hiding the
panel within a running Neovim instance. Snapshots live under
`stdpath('state')/opencode/session-tabs/`; concurrent instances in the same
workspace use the last saved snapshot.

To open a session in another worktree without changing your editor directory,
see [Worktree sessions](recipes/worktree.md).

Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ M.defaults = {
notify_on_background_prompt = true,
window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder',
persist_state = true,
restore_tabs_on_startup = true,
icons = {
preset = 'nerdfonts',
overrides = {},
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function M.setup(opts)

state = require('opencode.state')
state.session_tabs.setup()
require('opencode.services.session_tab_persistence').setup()
session_runtime.setup_subscriptions()
state.store.subscribe('opencode_server', on_opencode_server)
state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change)
Expand Down
82 changes: 81 additions & 1 deletion lua/opencode/services/session_runtime.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ local Promise = require('opencode.promise')
local log = require('opencode.log')
local agent_model = require('opencode.services.agent_model')
local session_tabs = require('opencode.state.session_tabs')
local tab_persistence = require('opencode.services.session_tab_persistence')

local M = {}

local active_binding
local directory_change_generation = 0
local group_panel_visible = false

local function release_active_observation()
local previous = active_binding
Expand Down Expand Up @@ -338,6 +341,13 @@ end

M.check_cwd = function()
local runtime = session_tabs.current()
if config.ui.restore_tabs_on_startup then
if runtime and runtime.bound_directory then
runtime.bound_editor_cwd = vim.fn.getcwd()
state.context.set_current_cwd(runtime.bound_directory)
end
return
end
if runtime and runtime.bound_directory then
local cwd = vim.fn.getcwd()
if runtime.bound_editor_cwd == cwd or M.is_session_locked(cwd) then
Expand All @@ -363,9 +373,21 @@ M.check_cwd = function()
end
end

local function prepare_group_switch()
group_panel_visible = group_panel_visible or state.ui.is_visible()
state.ui.set_opening(false)
session_tabs.set_context(context.snapshot())
if state.ui.is_visible() then
ui.prepare_session_tab_switch()
ui.hide_visible_windows(state.windows, true)
end
session_tabs.sync()
end

---@param opts? OpenOpts
M.open = Promise.async(function(opts)
opts = opts or { focus = 'input', new_session = false }
local editor_cwd = vim.fs.normalize(vim.fn.getcwd())

session_tabs.ensure_current()
session_tabs.set_context(context.snapshot())
Expand All @@ -374,7 +396,27 @@ M.open = Promise.async(function(opts)

local created_windows
local server_ok, server = pcall(function()
local previous = session_tabs.current()
local pinned = state.active_session and M.is_session_locked() and tab_persistence.is_initialized()
local restoring
if not pinned then
restoring = tab_persistence.restore(opts.new_session == true, prepare_group_switch)
end
if restoring then
restoring:await()
end
if vim.fs.normalize(vim.fn.getcwd()) ~= editor_cwd then
error('Working directory changed while opening panel')
end
local switched = session_tabs.current() ~= previous
if switched then
context.restore(session_tabs.get_context())
end
state.ui.set_opening(true)
local open_action = opts.open_action or state.ui.resolve_open_windows_action()
if switched and open_action == 'reuse_visible' then
open_action = state.ui.resolve_open_windows_action()
end
if opts.new_session then
context.clear_files()
context.clear_selections()
Expand All @@ -386,6 +428,7 @@ M.open = Promise.async(function(opts)
M.is_prompting_allowed()
end
created_windows = ui.prepare_windows(open_action, opts)
group_panel_visible = false
return server_job.ensure_server():await()
end)
if not server_ok then
Expand All @@ -409,7 +452,9 @@ M.open = Promise.async(function(opts)
else
agent_model.ensure_current_mode():await()
if not state.active_session then
state.session.set_active(last_workspace_session():await())
if not config.ui.restore_tabs_on_startup then
state.session.set_active(last_workspace_session():await())
end
if not state.active_session then
state.session.set_active(M.create_new_session():await())
end
Expand Down Expand Up @@ -1061,6 +1106,8 @@ end)

---@param cwd? string Directory from DirChanged; defaults to Neovim cwd.
M.handle_directory_change = Promise.async(function(cwd)
directory_change_generation = directory_change_generation + 1
local request = directory_change_generation
cwd = cwd or vim.fn.getcwd()
local locked = M.is_session_locked(cwd)
log.debug('Working directory change %s', vim.inspect({ cwd = cwd, locked = locked }))
Expand All @@ -1079,6 +1126,39 @@ M.handle_directory_change = Promise.async(function(cwd)
return
end

if config.ui.restore_tabs_on_startup then
local visible = state.ui.is_visible() or group_panel_visible
local previous = session_tabs.current()
local restoring = tab_persistence.restore(false, prepare_group_switch, cwd)
if restoring then
restoring:await()
end
-- A newer DirChanged owns the panel after an asynchronous lookup.
if request ~= directory_change_generation or vim.fs.normalize(vim.fn.getcwd()) ~= vim.fs.normalize(cwd) then
return
end
local current = session_tabs.current()
if current ~= previous or group_panel_visible then
context.restore(session_tabs.get_context())
if visible then
M.open({ focus = 'input', new_session = false, open_action = 'restore_hidden' }):await()
if state.active_session then
ui.render_output()
end
end
group_panel_visible = false
end
for _, tab in ipairs(session_tabs.list()) do
if tab.bound_directory then
tab.bound_editor_cwd = cwd
end
end
if current and not current.bound_directory then
state.context.set_current_cwd(current.current_cwd or cwd)
end
return
end

vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO)

local runtime = session_tabs.current()
Expand Down
Loading
Loading