Skip to content

Commit 5807fed

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 c25c8c3 commit 5807fed

File tree

13 files changed

+1468
-99
lines changed

13 files changed

+1468
-99
lines changed

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

@@ -1052,6 +1108,13 @@ M.commands = {
10521108
end,
10531109
},
10541110

1111+
hide = {
1112+
desc = 'Hide opencode windows (preserve buffers for fast restore)',
1113+
fn = function(args)
1114+
M.hide()
1115+
end,
1116+
},
1117+
10551118
cancel = {
10561119
desc = 'Cancel running request',
10571120
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: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ M.select_session = Promise.async(function(parent_id)
2525

2626
ui.select_session(filtered_sessions, function(selected_session)
2727
if not selected_session then
28-
if state.windows then
28+
if state.is_visible() then
2929
ui.focus_input()
3030
end
3131
return
@@ -42,8 +42,8 @@ M.switch_session = Promise.async(function(session_id)
4242
M.ensure_current_mode():await()
4343

4444
state.active_session = selected_session
45-
if state.windows then
46-
state.restore_points = {}
45+
state.restore_points = {}
46+
if state.is_visible() then
4747
ui.focus_input()
4848
else
4949
M.open()
@@ -52,7 +52,7 @@ end)
5252

5353
---@param opts? OpenOpts
5454
M.open_if_closed = Promise.async(function(opts)
55-
if not state.windows then
55+
if not state.is_visible() then
5656
M.open(opts):await()
5757
end
5858
end)
@@ -67,16 +67,32 @@ M.open = Promise.async(function(opts)
6767
require('opencode.context').load()
6868
end
6969

70-
local are_windows_closed = state.windows == nil
70+
local open_windows_action = opts.open_action or state.resolve_open_windows_action()
71+
local are_windows_closed = open_windows_action ~= 'reuse_visible'
72+
local restoring_hidden = open_windows_action == 'restore_hidden'
73+
7174
if are_windows_closed then
72-
-- Check if whether prompting will be allowed
75+
if not ui.is_opencode_focused() then
76+
state.last_code_win_before_opencode = vim.api.nvim_get_current_win()
77+
state.current_code_buf = vim.api.nvim_get_current_buf()
78+
end
79+
7380
local mentioned_files = context.get_context().mentioned_files or {}
7481
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
7582
if not allowed then
7683
vim.notify(err_msg or 'Prompts will be denied by prompt_guard', vim.log.levels.WARN)
7784
end
7885

79-
state.windows = ui.create_windows()
86+
if restoring_hidden then
87+
local restored = ui.restore_hidden_windows()
88+
if not restored then
89+
state.clear_hidden_window_state()
90+
restoring_hidden = false
91+
state.windows = ui.create_windows()
92+
end
93+
else
94+
state.windows = ui.create_windows()
95+
end
8096
end
8197

8298
if opts.focus == 'input' then
@@ -111,13 +127,14 @@ M.open = Promise.async(function(opts)
111127
state.active_session = M.create_new_session():await()
112128
else
113129
M.ensure_current_mode():await()
130+
114131
if not state.active_session then
115132
state.active_session = session.get_last_workspace_session():await()
116133
if not state.active_session then
117134
state.active_session = M.create_new_session():await()
118135
end
119136
else
120-
if not state.display_route and are_windows_closed then
137+
if not state.display_route and are_windows_closed and not restoring_hidden then
121138
-- We're not displaying /help or something like that but we have an active session
122139
-- and the windows were closed so we need to do a full refresh. This mostly happens
123140
-- when opening the window after having closed it since we're not currently clearing
@@ -257,7 +274,7 @@ end
257274
function M.configure_provider()
258275
require('opencode.model_picker').select(function(selection)
259276
if not selection then
260-
if state.windows then
277+
if state.is_visible() then
261278
ui.focus_input()
262279
end
263280
return
@@ -271,7 +288,7 @@ function M.configure_provider()
271288
state.user_mode_model_map = mode_map
272289
end
273290

274-
if state.windows then
291+
if state.is_visible() then
275292
ui.focus_input()
276293
else
277294
vim.notify('Changed provider to ' .. model_str, vim.log.levels.INFO)
@@ -282,15 +299,15 @@ end
282299
function M.configure_variant()
283300
require('opencode.variant_picker').select(function(selection)
284301
if not selection then
285-
if state.windows then
302+
if state.is_visible() then
286303
ui.focus_input()
287304
end
288305
return
289306
end
290307

291308
state.current_variant = selection.name
292309

293-
if state.windows then
310+
if state.is_visible() then
294311
ui.focus_input()
295312
else
296313
vim.notify('Changed variant to ' .. selection.name, vim.log.levels.INFO)
@@ -355,41 +372,42 @@ M.cycle_variant = Promise.async(function()
355372
end)
356373

357374
M.cancel = Promise.async(function()
358-
if state.windows and state.active_session then
359-
if state.is_running() then
360-
M._abort_count = M._abort_count + 1
361-
362-
local permissions = state.pending_permissions or {}
363-
if #permissions and state.api_client then
364-
for _, permission in ipairs(permissions) do
365-
require('opencode.api').permission_deny(permission)
366-
end
367-
end
375+
if state.active_session and state.is_running() then
376+
M._abort_count = M._abort_count + 1
368377

369-
local ok, result = pcall(function()
370-
return state.api_client:abort_session(state.active_session.id):wait()
371-
end)
372-
373-
if not ok then
374-
vim.notify('Abort error: ' .. vim.inspect(result))
378+
local permissions = state.pending_permissions or {}
379+
if #permissions and state.api_client then
380+
for _, permission in ipairs(permissions) do
381+
require('opencode.api').permission_deny(permission)
375382
end
383+
end
376384

377-
if M._abort_count >= 3 then
378-
vim.notify('Re-starting Opencode server')
379-
M._abort_count = 0
380-
-- close existing server
381-
if state.opencode_server then
382-
state.opencode_server:shutdown():await()
383-
end
385+
local ok, result = pcall(function()
386+
return state.api_client:abort_session(state.active_session.id):wait()
387+
end)
384388

385-
-- start a new one
386-
state.opencode_server = nil
389+
if not ok then
390+
vim.notify('Abort error: ' .. vim.inspect(result))
391+
end
387392

388-
-- NOTE: start a new server here to make sure we're subscribed
389-
-- to server events before a user sends a message
390-
state.opencode_server = server_job.ensure_server():await() --[[@as OpencodeServer]]
393+
if M._abort_count >= 3 then
394+
vim.notify('Re-starting Opencode server')
395+
M._abort_count = 0
396+
-- close existing server
397+
if state.opencode_server then
398+
state.opencode_server:shutdown():await()
391399
end
400+
401+
-- start a new one
402+
state.opencode_server = nil
403+
404+
-- NOTE: start a new server here to make sure we're subscribed
405+
-- to server events before a user sends a message
406+
state.opencode_server = server_job.ensure_server():await() --[[@as OpencodeServer]]
392407
end
408+
end
409+
410+
if state.is_visible() then
393411
require('opencode.ui.footer').clear()
394412
input_window.set_content('')
395413
require('opencode.history').index = nil

0 commit comments

Comments
 (0)