Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ require('opencode').setup({
position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output.
input_position = 'bottom', -- 'bottom' (default) or 'top'. Position of the input window
window_width = 0.40, -- Width as percentage of editor width
persist_state = true, -- Preserve buffers and view state when toggling UI off (default: true). Hidden state reopens at the old cursor unless output was at bottom (then it follows latest output).
zoom_width = 0.8, -- Zoom width as percentage of editor width
display_model = true, -- Display model name on top winbar
display_context_size = true, -- Display context size in the footer
Expand Down
98 changes: 90 additions & 8 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,51 @@ local Promise = require('opencode.promise')

local M = {}

---Get the current window state of opencode
---@return {status: 'closed'|'hidden'|'visible', visible: boolean, hidden: boolean, position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}}
function M.get_window_state()
---@type OpencodeWindowState|nil
local windows = state.windows
local status = state.get_window_status()

---@param win_id integer|nil
---@return integer[]|nil
local function get_window_cursor(win_id)
if not win_id or not vim.api.nvim_win_is_valid(win_id) then
return nil
end
local ok, pos = pcall(vim.api.nvim_win_get_cursor, win_id)
if ok then
return pos
end
return nil
end

local input_cursor = get_window_cursor(windows and windows.input_win)
if input_cursor == nil then
input_cursor = state.get_cursor_position('input')
end

local output_cursor = get_window_cursor(windows and windows.output_win)
if output_cursor == nil then
output_cursor = state.get_cursor_position('output')
end

local cursor_positions = {
input = input_cursor,
output = output_cursor,
}

return {
status = status,
visible = status == 'visible',
hidden = status == 'hidden',
position = config.ui.position,
windows = windows,
cursor_positions = cursor_positions,
}
end

function M.swap_position()
require('opencode.ui.ui').swap_position()
end
Expand Down Expand Up @@ -51,36 +96,66 @@ function M.close()
ui.close_windows(state.windows)
end

-- Hide the UI but keep buffers for fast restore.
function M.hide()
ui.close_windows(state.windows, true)
end

function M.paste_image()
core.paste_image_from_clipboard()
end

--- Check if opencode windows are in the current tab page
--- @return boolean
local function are_windows_in_current_tab()
if not state.windows or not state.windows.output_win then
if not state.windows then
return false
end

local current_tab = vim.api.nvim_get_current_tabpage()
local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, state.windows.output_win)
return ok and win_tab == current_tab

local function is_window_in_current_tab(win_id)
if not win_id or not vim.api.nvim_win_is_valid(win_id) then
return false
end
local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, win_id)
return ok and win_tab == current_tab
end

return is_window_in_current_tab(state.windows.input_win)
or is_window_in_current_tab(state.windows.output_win)
end

M.toggle = Promise.async(function(new_session)
-- When auto_hide input is enabled, always focus input; otherwise use last focused
local focus = 'input' ---@cast focus 'input' | 'output'
if not config.ui.input.auto_hide then
focus = state.last_focused_opencode_window or 'input'
end

if state.windows == nil or not are_windows_in_current_tab() then
if state.windows then
M.close()
local function is_valid_win(win_id)
return win_id ~= nil and vim.api.nvim_win_is_valid(win_id)
end

local windows_open = state.windows ~= nil
and (is_valid_win(state.windows.input_win) or is_valid_win(state.windows.output_win))
and are_windows_in_current_tab()

if not windows_open then
if state.windows and not state.has_hidden_buffers() then
state.windows = nil
end
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
else
M.close()
if state.display_route then
M.close()
return
end

if config.ui.persist_state then
M.hide()
else
M.close()
end
end
end)

Expand Down Expand Up @@ -1037,6 +1112,13 @@ M.commands = {
end,
},

hide = {
desc = 'Hide opencode windows (preserve buffers for fast restore)',
fn = function(args)
M.hide()
end,
},

cancel = {
desc = 'Cancel running request',
fn = M.cancel,
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ M.defaults = {
position = 'right',
input_position = 'bottom',
window_width = 0.40,
persist_state = true,
zoom_width = 0.8,
picker_width = 100,
display_model = true,
Expand Down
23 changes: 17 additions & 6 deletions lua/opencode/core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ M.open = Promise.async(function(opts)
require('opencode.context').load()
end

local are_windows_closed = state.windows == nil
local window_status = state.get_window_status()
local are_windows_closed = window_status ~= 'visible'
local restoring_hidden = window_status == 'hidden' and ui.has_hidden_buffers()
if are_windows_closed then
-- Check if whether prompting will be allowed
local mentioned_files = context.get_context().mentioned_files or {}
Expand All @@ -79,6 +81,16 @@ M.open = Promise.async(function(opts)
state.windows = ui.create_windows()
end

local should_sync_on_restore = restoring_hidden
and state.windows
and state.windows.output_was_at_bottom == true
-- Preserve reading context when reopening from hidden state:
-- only resync output when the user was previously following the bottom.

if are_windows_closed then
ui.focus_output({ restore_position = true })
end

if opts.focus == 'input' then
ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true })
elseif opts.focus == 'output' then
Expand Down Expand Up @@ -117,11 +129,10 @@ M.open = Promise.async(function(opts)
state.active_session = M.create_new_session():await()
end
else
if not state.display_route and are_windows_closed then
-- We're not displaying /help or something like that but we have an active session
-- and the windows were closed so we need to do a full refresh. This mostly happens
-- when opening the window after having closed it since we're not currently clearing
-- the session on api.close()
if not state.display_route
and are_windows_closed
and (not restoring_hidden or should_sync_on_restore)
then
ui.render_output()
end
end
Expand Down
135 changes: 128 additions & 7 deletions lua/opencode/state.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
---@field footer_buf integer|nil
---@field input_buf integer|nil
---@field output_buf integer|nil
---@field output_was_at_bottom boolean|nil

---@class OpencodeHiddenBuffers
---@field input_buf integer
---@field output_buf integer
---@field input_was_visible boolean
---@field output_was_at_bottom boolean|nil

---@class OpencodeState
---@field windows OpencodeWindowState|nil
Expand Down Expand Up @@ -43,13 +50,11 @@
---@field pre_zoom_width integer|nil
---@field required_version string
---@field opencode_cli_version string|nil
---@field append fun( key:string, value:any)
---@field remove fun( key:string, idx:number)
---@field subscribe fun( key:string|nil, cb:fun(key:string, new_val:any, old_val:any))
---@field subscribe fun( key:string|string[]|nil, cb:fun(key:string, new_val:any, old_val:any))
---@field unsubscribe fun( key:string|nil, cb:fun(key:string, new_val:any, old_val:any))
---@field is_running fun():boolean
---@field _hidden_buffers OpencodeHiddenBuffers|nil

---@class OpencodeStateModule: OpencodeState

---@type OpencodeStateModule
local M = {}

-- Internal raw state table
Expand Down Expand Up @@ -196,6 +201,122 @@ function M.is_running()
return M.job_count > 0
end

---Get the current window status
---@return 'closed'|'hidden'|'visible'
function M.get_window_status()
-- Check hidden buffers first to avoid race condition during close_windows()
if M.has_hidden_buffers() then
return 'hidden'
end

local windows = _state.windows
local input_visible = windows
and windows.input_win ~= nil
and vim.api.nvim_win_is_valid(windows.input_win)
local output_visible = windows
and windows.output_win ~= nil
and vim.api.nvim_win_is_valid(windows.output_win)

if input_visible or output_visible then
return 'visible'
end

return 'closed'
end

---Save cursor position for a window
---@param win_type 'input'|'output'
---@param win_id integer|nil
function M.save_cursor_position(win_type, win_id)
if not win_id or not vim.api.nvim_win_is_valid(win_id) then
return
end
local ok, pos = pcall(vim.api.nvim_win_get_cursor, win_id)
if ok then
if win_type == 'input' then
_state.last_input_window_position = pos
elseif win_type == 'output' then
_state.last_output_window_position = pos
end
else
vim.notify('Failed to save cursor position: ' .. tostring(pos), vim.log.levels.DEBUG)
end
end

---Get saved cursor position
---@param win_type 'input'|'output'
---@return integer[]|nil
function M.get_cursor_position(win_type)
if win_type == 'input' then
return _state.last_input_window_position
elseif win_type == 'output' then
return _state.last_output_window_position
end
return nil
end

---Store hidden buffers for persist_state feature
---@param input_buf integer|nil
---@param output_buf integer|nil
---@param input_was_visible boolean
---@param output_was_at_bottom? boolean
function M.stash_hidden_buffers(input_buf, output_buf, input_was_visible, output_was_at_bottom)
_state._hidden_buffers = {
input_buf = input_buf,
output_buf = output_buf,
input_was_visible = input_was_visible,
output_was_at_bottom = output_was_at_bottom,
}
end

---Clear hidden buffer snapshot
function M.clear_hidden_buffers()
_state._hidden_buffers = nil
end

---Peek at hidden buffers without consuming them (check only)
---@return boolean
function M.has_hidden_buffers()
local hidden = _state._hidden_buffers
if not hidden then
return false
end

if type(hidden.input_buf) ~= 'number' or type(hidden.output_buf) ~= 'number' then
_state._hidden_buffers = nil
return false
end

return vim.api.nvim_buf_is_valid(hidden.input_buf)
and vim.api.nvim_buf_is_valid(hidden.output_buf)
end

---Consume hidden buffers (returns and clears them)
---@return {input_buf: integer|nil, output_buf: integer|nil, input_was_visible: boolean, output_was_at_bottom: boolean|nil}|nil
function M.consume_hidden_buffers()
local hidden = _state._hidden_buffers
if not hidden then
return nil
end

-- Consume: clear state immediately
_state._hidden_buffers = nil

-- Validate types
if type(hidden.input_buf) ~= 'number' or type(hidden.output_buf) ~= 'number' then
return nil
end

-- Validate buffer validity
local input_valid = vim.api.nvim_buf_is_valid(hidden.input_buf)
local output_valid = vim.api.nvim_buf_is_valid(hidden.output_buf)
if not input_valid or not output_valid then
return nil
end

return hidden
end

--- Observable state proxy. All reads/writes go through this table.
--- Use `state.subscribe(key, cb)` to listen for changes.
--- Use `state.unsubscribe(key, cb)` to remove listeners.
Expand All @@ -220,4 +341,4 @@ return setmetatable(M, {
__ipairs = function()
return ipairs(_state)
end,
}) --[[@as OpencodeState]]
}) --[[@as OpencodeStateModule]]
1 change: 1 addition & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
---@field position 'right'|'left'|'current' # Position of the UI (default: 'right')
---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom')
---@field window_width number
---@field persist_state boolean
---@field zoom_width number
---@field picker_width number|nil # Default width for all pickers (nil uses current window width)
---@field display_model boolean
Expand Down
9 changes: 8 additions & 1 deletion lua/opencode/ui/input_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,11 @@ function M.setup(windows)
M.update_dimensions(windows)
M.refresh_placeholder(windows)
M.setup_keymaps(windows)
M.recover_input(windows)
-- Only recover input if buffer is empty (not restored from hidden state)
local lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false)
if #lines == 0 or (#lines == 1 and lines[1] == '') then
M.recover_input(windows)
end

require('opencode.ui.context_bar').render(windows)
end
Expand Down Expand Up @@ -480,6 +484,9 @@ function M.setup_autocmds(windows, group)
group = group,
buffer = windows.input_buf,
callback = function()
-- Save cursor position before leaving
state.save_cursor_position('input', windows.input_win)

-- Auto-hide input window when auto_hide is enabled and focus leaves
-- Don't hide if displaying a route (slash command output like /help)
-- Don't hide if input contains content
Expand Down
Loading
Loading