Skip to content

Commit 0eba52d

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 0eba52d

13 files changed

Lines changed: 1500 additions & 123 deletions

File tree

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: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,40 +48,135 @@ 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+
---@param win_id integer|nil
63+
---@return integer[]|nil
64+
local function get_window_cursor(win_id)
65+
if not win_id or not vim.api.nvim_win_is_valid(win_id) then
66+
return nil
67+
end
68+
69+
local ok, pos = pcall(vim.api.nvim_win_get_cursor, win_id)
70+
if not ok then
71+
return nil
72+
end
73+
74+
return pos
75+
end
76+
77+
---@return {status: 'closed'|'hidden'|'visible', visible: boolean, hidden: boolean, position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}}
78+
function M.get_window_state()
79+
local status = state.get_window_status()
80+
local current_windows = nil
81+
82+
if status == 'visible' and state.are_windows_in_current_tab() then
83+
current_windows = state.windows
84+
elseif status == 'visible' then
85+
status = 'closed'
86+
end
87+
88+
local input_cursor = get_window_cursor(current_windows and current_windows.input_win)
89+
or state.get_cursor_position('input')
90+
local output_cursor = get_window_cursor(current_windows and current_windows.output_win)
91+
or state.get_cursor_position('output')
92+
93+
return {
94+
status = status,
95+
visible = status == 'visible',
96+
hidden = status == 'hidden',
97+
position = config.ui.position,
98+
windows = current_windows and vim.deepcopy(current_windows) or nil,
99+
cursor_positions = {
100+
input = input_cursor,
101+
output = output_cursor,
102+
},
103+
}
104+
end
105+
106+
---@param hidden OpencodeHiddenBuffers|nil
107+
---@return 'input'|'output'
108+
local function resolve_hidden_focus(hidden)
109+
if hidden and (hidden.focused_window == 'input' or hidden.focused_window == 'output') then
110+
return hidden.focused_window
111+
end
112+
113+
if hidden and hidden.input_hidden then
114+
return 'output'
115+
end
116+
117+
return 'input'
118+
end
119+
120+
---@param restore_hidden boolean
121+
---@return {focus: 'input'|'output', open_action: 'reuse_visible'|'restore_hidden'|'create_fresh'}
122+
local function build_toggle_open_context(restore_hidden)
123+
if restore_hidden then
124+
local hidden = state.inspect_hidden_buffers()
125+
return {
126+
focus = resolve_hidden_focus(hidden),
127+
open_action = 'restore_hidden',
128+
}
63129
end
64130

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
131+
local focus = config.ui.input.auto_hide and 'input'
132+
or state.last_focused_opencode_window
133+
or 'input'
134+
135+
return {
136+
focus = focus,
137+
open_action = 'create_fresh',
138+
}
68139
end
69140

70141
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'
142+
local decision = state.resolve_toggle_decision(
143+
config.ui.persist_state,
144+
state.display_route ~= nil
145+
)
146+
local action = decision.action
147+
148+
local EARLY_RETURN_ACTIONS = {
149+
close = M.close,
150+
hide = M.hide,
151+
close_hidden = ui.drop_hidden_snapshot,
152+
}
153+
154+
local function open_windows(restore_hidden)
155+
local ctx = build_toggle_open_context(restore_hidden == true)
156+
return core.open({
157+
new_session = new_session == true,
158+
focus = ctx.focus,
159+
start_insert = false,
160+
open_action = ctx.open_action,
161+
}):await()
75162
end
76163

77-
if state.windows == nil or not are_windows_in_current_tab() then
164+
local early_return = EARLY_RETURN_ACTIONS[action]
165+
if early_return then
166+
return early_return()
167+
end
168+
169+
if action == 'migrate' then
170+
-- NOTE: We currently don't support preserving Opencode UI state across tabs.
171+
-- If Opencode is visible in a different tab, we tear it down there and recreate it here.
78172
if state.windows then
79-
M.close()
173+
ui.teardown_visible_windows(state.windows)
80174
end
81-
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
82-
else
83-
M.close()
175+
176+
return open_windows(false)
84177
end
178+
179+
return open_windows(action == 'restore_hidden')
85180
end)
86181

87182
---@param new_session boolean?
@@ -271,7 +366,7 @@ function M.set_review_breakpoint()
271366
end
272367

273368
function M.prev_history()
274-
if not state.windows then
369+
if not state.is_visible() then
275370
return
276371
end
277372
local prev_prompt = history.prev()
@@ -282,7 +377,7 @@ function M.prev_history()
282377
end
283378

284379
function M.next_history()
285-
if not state.windows then
380+
if not state.is_visible() then
286381
return
287382
end
288383
local next_prompt = history.next()
@@ -517,7 +612,7 @@ function M.help()
517612
'|--------------|-------------|',
518613
}, false)
519614

520-
if not state.windows or not state.windows.output_win then
615+
if not state.is_visible() or not state.windows.output_win then
521616
return
522617
end
523618

@@ -1052,6 +1147,13 @@ M.commands = {
10521147
end,
10531148
},
10541149

1150+
hide = {
1151+
desc = 'Hide opencode windows (preserve buffers for fast restore)',
1152+
fn = function(args)
1153+
M.hide()
1154+
end,
1155+
},
1156+
10551157
cancel = {
10561158
desc = 'Cancel running request',
10571159
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 = {},

0 commit comments

Comments
 (0)