Skip to content

Commit eef2ee7

Browse files
committed
feat(ui): persist_state window/buffer persistence
When ui.persist_state = true (default), toggle hides windows without deleting buffers and restores them on next toggle, preserving input draft content, scroll/cursor position, focused pane, and input_hidden state. Three-state window model: closed → visible → hidden → visible ↑___________↓____________↑ (teardown) (restore) Decision engine resolves (status, persist_state, has_display_route, in_tab) → action: | # | status | in_tab | persist_state | has_display_route | action | |---|---------|--------|---------------|-------------------|----------------| | 1 | hidden | any | true | any | restore_hidden | | 2 | hidden | any | false | any | close_hidden | | 3 | visible | false | any | any | migrate | | 4 | visible | true | any | true | close | | 5 | visible | true | false | any | close | | 6 | visible | true | true | false | hide | | 7 | closed | any | any | any | open | Note: Multiple opencode instances across tabs are not currently supported.
1 parent ba94536 commit eef2ee7

16 files changed

Lines changed: 1488 additions & 106 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ require('opencode').setup({
209209
display_context_size = true, -- Display context size in the footer
210210
display_cost = true, -- Display cost in the footer
211211
window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', -- Highlight group for the opencode window
212+
persist_state = true, -- Keep buffers when toggling/closing UI so window state restores quickly
212213
icons = {
213214
preset = 'nerdfonts', -- 'nerdfonts' | 'text'. Choose UI icon style (default: 'nerdfonts')
214215
overrides = {}, -- Optional per-key overrides, see section below
@@ -441,6 +442,19 @@ Available icon keys (see implementation at lua/opencode/ui/icons.lua lines 7-29)
441442
- status_on, status_off
442443
- border, bullet
443444

445+
### Window Persistence Behavior
446+
447+
`ui.persist_state` controls how `toggle` behaves:
448+
449+
- `persist_state = true` (default): `toggle()` hides/restores the UI and keeps buffers/session view in memory for fast restore.
450+
- `persist_state = false`: `toggle()` fully tears down UI buffers and recreates them on next open.
451+
452+
Related APIs:
453+
454+
- `require('opencode.api').toggle()` follows the `persist_state` behavior above.
455+
- `require('opencode.api').close()` always fully closes and clears hidden snapshot state.
456+
- `require('opencode.api').hide()` preserves buffers only when `persist_state = true`; otherwise it behaves like close.
457+
444458
### Picker Layout
445459

446460
You can customize the layout of the picker used for history, session, references, and timeline

lua/opencode/api.lua

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,40 +48,96 @@ function M.close()
4848
return
4949
end
5050

51-
ui.close_windows(state.windows)
51+
ui.teardown_visible_windows(state.windows)
52+
end
53+
54+
function M.hide()
55+
ui.hide_visible_windows(state.windows)
5256
end
5357

5458
function M.paste_image()
5559
core.paste_image_from_clipboard()
5660
end
5761

58-
--- Check if opencode windows are in the current tab page
59-
--- @return boolean
60-
local function are_windows_in_current_tab()
61-
if not state.windows or not state.windows.output_win then
62-
return false
62+
---@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}}
63+
function M.get_window_state()
64+
return state.get_window_state()
65+
end
66+
67+
---@param hidden OpencodeHiddenBuffers|nil
68+
---@return 'input'|'output'
69+
local function resolve_hidden_focus(hidden)
70+
if hidden and (hidden.focused_window == 'input' or hidden.focused_window == 'output') then
71+
return hidden.focused_window
6372
end
6473

65-
local current_tab = vim.api.nvim_get_current_tabpage()
66-
local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, state.windows.output_win)
67-
return ok and win_tab == current_tab
74+
if hidden and hidden.input_hidden then
75+
return 'output'
76+
end
77+
78+
return 'input'
79+
end
80+
81+
---@param restore_hidden boolean
82+
---@return {focus: 'input'|'output', open_action: 'reuse_visible'|'restore_hidden'|'create_fresh'}
83+
local function build_toggle_open_context(restore_hidden)
84+
if restore_hidden then
85+
local hidden = state.inspect_hidden_buffers()
86+
return {
87+
focus = resolve_hidden_focus(hidden),
88+
open_action = 'restore_hidden',
89+
}
90+
end
91+
92+
local focus = config.ui.input.auto_hide and 'input'
93+
or state.last_focused_opencode_window
94+
or 'input'
95+
96+
return {
97+
focus = focus,
98+
open_action = 'create_fresh',
99+
}
68100
end
69101

70102
M.toggle = Promise.async(function(new_session)
71-
-- When auto_hide input is enabled, always focus input; otherwise use last focused
72-
local focus = 'input' ---@cast focus 'input' | 'output'
73-
if not config.ui.input.auto_hide then
74-
focus = state.last_focused_opencode_window or 'input'
103+
local decision = state.resolve_toggle_decision(
104+
config.ui.persist_state,
105+
state.display_route ~= nil
106+
)
107+
local action = decision.action
108+
109+
local EARLY_RETURN_ACTIONS = {
110+
close = M.close,
111+
hide = M.hide,
112+
close_hidden = ui.drop_hidden_snapshot,
113+
}
114+
115+
local function open_windows(restore_hidden)
116+
local ctx = build_toggle_open_context(restore_hidden == true)
117+
return core.open({
118+
new_session = new_session == true,
119+
focus = ctx.focus,
120+
start_insert = false,
121+
open_action = ctx.open_action,
122+
}):await()
123+
end
124+
125+
local early_return = EARLY_RETURN_ACTIONS[action]
126+
if early_return then
127+
return early_return()
75128
end
76129

77-
if state.windows == nil or not are_windows_in_current_tab() then
130+
if action == 'migrate' then
131+
-- NOTE: We currently don't support preserving Opencode UI state across tabs.
132+
-- If Opencode is visible in a different tab, we tear it down there and recreate it here.
78133
if state.windows then
79-
M.close()
134+
ui.teardown_visible_windows(state.windows)
80135
end
81-
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
82-
else
83-
M.close()
136+
137+
return open_windows(false)
84138
end
139+
140+
return open_windows(action == 'restore_hidden')
85141
end)
86142

87143
---@param new_session boolean?
@@ -271,7 +327,7 @@ function M.set_review_breakpoint()
271327
end
272328

273329
function M.prev_history()
274-
if not state.windows then
330+
if not state.is_visible() then
275331
return
276332
end
277333
local prev_prompt = history.prev()
@@ -282,7 +338,7 @@ function M.prev_history()
282338
end
283339

284340
function M.next_history()
285-
if not state.windows then
341+
if not state.is_visible() then
286342
return
287343
end
288344
local next_prompt = history.next()
@@ -517,7 +573,7 @@ function M.help()
517573
'|--------------|-------------|',
518574
}, false)
519575

520-
if not state.windows or not state.windows.output_win then
576+
if not state.is_visible() or not state.windows.output_win then
521577
return
522578
end
523579

@@ -1051,6 +1107,13 @@ M.commands = {
10511107
end,
10521108
},
10531109

1110+
hide = {
1111+
desc = 'Hide opencode windows (preserve buffers for fast restore)',
1112+
fn = function(args)
1113+
M.hide()
1114+
end,
1115+
},
1116+
10541117
cancel = {
10551118
desc = 'Cancel running request',
10561119
fn = M.cancel,

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ M.defaults = {
116116
display_context_size = true,
117117
display_cost = true,
118118
window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder',
119+
persist_state = true,
119120
icons = {
120121
preset = 'nerdfonts',
121122
overrides = {},

lua/opencode/core.lua

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ M.select_session = Promise.async(function(parent_id)
2626

2727
ui.select_session(filtered_sessions, function(selected_session)
2828
if not selected_session then
29-
if state.windows then
29+
if state.is_visible() then
3030
ui.focus_input()
3131
end
3232
return
@@ -43,8 +43,8 @@ M.switch_session = Promise.async(function(session_id)
4343
M.ensure_current_mode():await()
4444

4545
state.active_session = selected_session
46-
if state.windows then
47-
state.restore_points = {}
46+
state.restore_points = {}
47+
if state.is_visible() then
4848
ui.focus_input()
4949
else
5050
M.open()
@@ -53,7 +53,7 @@ end)
5353

5454
---@param opts? OpenOpts
5555
M.open_if_closed = Promise.async(function(opts)
56-
if not state.windows then
56+
if not state.is_visible() then
5757
M.open(opts):await()
5858
end
5959
end)
@@ -89,10 +89,28 @@ M.open = Promise.async(function(opts)
8989
require('opencode.context').load()
9090
end
9191

92-
local are_windows_closed = state.windows == nil
92+
local open_windows_action = opts.open_action or state.resolve_open_windows_action()
93+
local are_windows_closed = open_windows_action ~= 'reuse_visible'
94+
local restoring_hidden = open_windows_action == 'restore_hidden'
95+
9396
if are_windows_closed then
97+
if not ui.is_opencode_focused() then
98+
state.last_code_win_before_opencode = vim.api.nvim_get_current_win()
99+
state.current_code_buf = vim.api.nvim_get_current_buf()
100+
end
101+
94102
M.is_prompting_allowed()
95-
state.windows = ui.create_windows()
103+
104+
if restoring_hidden then
105+
local restored = ui.restore_hidden_windows()
106+
if not restored then
107+
state.clear_hidden_window_state()
108+
restoring_hidden = false
109+
state.windows = ui.create_windows()
110+
end
111+
else
112+
state.windows = ui.create_windows()
113+
end
96114
end
97115

98116
if opts.focus == 'input' then
@@ -122,13 +140,14 @@ M.open = Promise.async(function(opts)
122140
log.debug('Created new session on open', { session = state.active_session.id })
123141
else
124142
M.ensure_current_mode():await()
143+
125144
if not state.active_session then
126145
state.active_session = session.get_last_workspace_session():await()
127146
if not state.active_session then
128147
state.active_session = M.create_new_session():await()
129148
end
130149
else
131-
if not state.display_route and are_windows_closed then
150+
if not state.display_route and are_windows_closed and not restoring_hidden then
132151
-- We're not displaying /help or something like that but we have an active session
133152
-- and the windows were closed so we need to do a full refresh. This mostly happens
134153
-- when opening the window after having closed it since we're not currently clearing
@@ -268,7 +287,7 @@ end
268287
function M.configure_provider()
269288
require('opencode.model_picker').select(function(selection)
270289
if not selection then
271-
if state.windows then
290+
if state.is_visible() then
272291
ui.focus_input()
273292
end
274293
return
@@ -282,7 +301,7 @@ function M.configure_provider()
282301
state.user_mode_model_map = mode_map
283302
end
284303

285-
if state.windows then
304+
if state.is_visible() then
286305
ui.focus_input()
287306
else
288307
vim.notify('Changed provider to ' .. model_str, vim.log.levels.INFO)
@@ -293,15 +312,15 @@ end
293312
function M.configure_variant()
294313
require('opencode.variant_picker').select(function(selection)
295314
if not selection then
296-
if state.windows then
315+
if state.is_visible() then
297316
ui.focus_input()
298317
end
299318
return
300319
end
301320

302321
state.current_variant = selection.name
303322

304-
if state.windows then
323+
if state.is_visible() then
305324
ui.focus_input()
306325
else
307326
vim.notify('Changed variant to ' .. selection.name, vim.log.levels.INFO)
@@ -366,41 +385,42 @@ M.cycle_variant = Promise.async(function()
366385
end)
367386

368387
M.cancel = Promise.async(function()
369-
if state.windows and state.active_session then
370-
if state.is_running() then
371-
M._abort_count = M._abort_count + 1
372-
373-
local permissions = state.pending_permissions or {}
374-
if #permissions and state.api_client then
375-
for _, permission in ipairs(permissions) do
376-
require('opencode.api').permission_deny(permission)
377-
end
378-
end
388+
if state.active_session and state.is_running() then
389+
M._abort_count = M._abort_count + 1
379390

380-
local ok, result = pcall(function()
381-
return state.api_client:abort_session(state.active_session.id):wait()
382-
end)
383-
384-
if not ok then
385-
vim.notify('Abort error: ' .. vim.inspect(result))
391+
local permissions = state.pending_permissions or {}
392+
if #permissions and state.api_client then
393+
for _, permission in ipairs(permissions) do
394+
require('opencode.api').permission_deny(permission)
386395
end
396+
end
387397

388-
if M._abort_count >= 3 then
389-
vim.notify('Re-starting Opencode server')
390-
M._abort_count = 0
391-
-- close existing server
392-
if state.opencode_server then
393-
state.opencode_server:shutdown():await()
394-
end
398+
local ok, result = pcall(function()
399+
return state.api_client:abort_session(state.active_session.id):wait()
400+
end)
395401

396-
-- start a new one
397-
state.opencode_server = nil
402+
if not ok then
403+
vim.notify('Abort error: ' .. vim.inspect(result))
404+
end
398405

399-
-- NOTE: start a new server here to make sure we're subscribed
400-
-- to server events before a user sends a message
401-
state.opencode_server = server_job.ensure_server():await() --[[@as OpencodeServer]]
406+
if M._abort_count >= 3 then
407+
vim.notify('Re-starting Opencode server')
408+
M._abort_count = 0
409+
-- close existing server
410+
if state.opencode_server then
411+
state.opencode_server:shutdown():await()
402412
end
413+
414+
-- start a new one
415+
state.opencode_server = nil
416+
417+
-- NOTE: start a new server here to make sure we're subscribed
418+
-- to server events before a user sends a message
419+
state.opencode_server = server_job.ensure_server():await() --[[@as OpencodeServer]]
403420
end
421+
end
422+
423+
if state.is_visible() then
404424
require('opencode.ui.footer').clear()
405425
input_window.set_content('')
406426
require('opencode.history').index = nil

0 commit comments

Comments
 (0)