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
26 changes: 21 additions & 5 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ function M.toggle_zoom()
require('opencode.ui.ui').toggle_zoom()
end

function M.toggle_input()
input_window.toggle()
end

function M.open_input()
return core.open({ new_session = false, focus = 'input', start_insert = true })
end
Expand Down Expand Up @@ -52,7 +56,11 @@ function M.paste_image()
end

M.toggle = Promise.async(function(new_session)
local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output'
-- 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 then
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
else
Expand Down Expand Up @@ -144,9 +152,7 @@ function M.quick_chat(message, range)
end

function M.toggle_pane()
return core.open({ new_session = false, focus = 'output' }):and_then(function()
ui.toggle_pane()
end)
ui.toggle_pane()
end

---@param from_snapshot_id? string
Expand Down Expand Up @@ -306,7 +312,12 @@ M.submit_input_prompt = Promise.async(function()
ui.render_output(true)
end

input_window.handle_submit()
local message_sent = input_window.handle_submit()

-- Only hide input window if a message was actually sent (not slash commands, shell commands, etc.)
if message_sent and config.ui.input.auto_hide and not input_window.is_hidden() then
input_window._hide()
end
end)

function M.mention_file()
Expand Down Expand Up @@ -1006,6 +1017,11 @@ M.commands = {
fn = M.toggle_zoom,
},

toggle_input = {
desc = 'Toggle input window visibility',
fn = M.toggle_input,
},

quick_chat = {
desc = 'Quick chat with current buffer or visual selection',
fn = M.quick_chat,
Expand Down
8 changes: 6 additions & 2 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ M.defaults = {
['<C-c>'] = { 'cancel' },
[']]'] = { 'next_message' },
['[['] = { 'prev_message' },
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
['<tab>'] = { 'toggle_pane', mode = { 'n' } },
['i'] = { 'focus_input' },
['gr'] = { 'references', desc = 'Browse code references' },
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
['<leader>oS'] = { 'select_child_session' },
['<leader>oD'] = { 'debug_message' },
['<leader>oO'] = { 'debug_output' },
Expand All @@ -67,10 +68,11 @@ M.defaults = {
['/'] = { 'slash_commands', mode = 'i' },
['#'] = { 'context_items', mode = 'i' },
['<M-v>'] = { 'paste_image', mode = 'i' },
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
['<tab>'] = { 'toggle_pane', mode = { 'n' } },
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } },
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } },
['<M-m>'] = { 'switch_mode', mode = { 'n', 'i' } },
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
['gr'] = { 'references', desc = 'Browse code references' },
['<leader>oS'] = { 'select_child_session' },
['<leader>oD'] = { 'debug_message' },
Expand Down Expand Up @@ -134,6 +136,8 @@ M.defaults = {
text = {
wrap = false,
},
-- Auto-hide input window when prompt is submitted or focus switches to output window
auto_hide = false,
},
completion = {
file_sources = {
Expand Down
5 changes: 5 additions & 0 deletions lua/opencode/ui/autocmds.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ function M.setup_autocmds(windows)
group = group,
pattern = table.concat(wins, ','),
callback = function(opts)
-- Don't close everything if we're just toggling the input window
if input_window._toggling then
return
end

local closed_win = tonumber(opts.match)
if vim.tbl_contains(wins, closed_win) then
vim.schedule(function()
Expand Down
125 changes: 121 additions & 4 deletions lua/opencode/ui/input_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ local state = require('opencode.state')
local config = require('opencode.config')
local M = {}

-- Track hidden state
M._hidden = false
-- Flag to prevent WinClosed autocmd from closing all windows during toggle
M._toggling = false

function M.create_buf()
local input_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('filetype', 'opencode', { buf = input_buf })
Expand Down Expand Up @@ -48,10 +53,12 @@ function M.close()
pcall(vim.api.nvim_buf_delete, state.windows.input_buf, { force = true })
end

---Handle submit action from input window
---@return boolean true if a message was sent to the AI, false otherwise
function M.handle_submit()
local windows = state.windows
if not windows or not M.mounted(windows) then
return
return false
end
---@cast windows { input_buf: integer }

Expand All @@ -63,21 +70,22 @@ function M.handle_submit()
})

if input_content == '' then
return
return false
end

if input_content:match('^!') then
M._execute_shell_command(input_content:sub(2))
return
return false
end

local key = config.get_key_for_function('input_window', 'slash_commands') or '/'
if input_content:match('^' .. key) then
M._execute_slash_command(input_content)
return
return false
end

require('opencode.core').send_message(input_content)
return true
end

M._execute_shell_command = function(command)
Expand Down Expand Up @@ -267,6 +275,11 @@ function M.recover_input(windows)
end

function M.focus_input()
if M._hidden then
M._show()
return
end

if not M.mounted() then
return
end
Expand Down Expand Up @@ -350,6 +363,18 @@ function M.setup_autocmds(windows, group)
end,
})

vim.api.nvim_create_autocmd('WinLeave', {
group = group,
buffer = windows.input_buf,
callback = function()
-- Auto-hide input window when auto_hide is enabled and focus leaves
-- Don't hide if displaying a route (slash command output like /help)
if config.ui.input.auto_hide and not M.is_hidden() and not state.display_route then
M._hide()
end
end,
})

vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
buffer = windows.input_buf,
callback = function()
Expand All @@ -365,4 +390,96 @@ function M.setup_autocmds(windows, group)
end)
end

---Toggle the input window visibility (hide/show)
---When hidden, the input window is closed entirely
---When shown, the input window is recreated
function M.toggle()
local windows = state.windows
if not windows then
return
end

if M._hidden then
M._show()
else
M._hide()
end
end

---Hide the input window by closing it
function M._hide()
local windows = state.windows
if not M.mounted(windows) then
return
end

local output_window = require('opencode.ui.output_window')
local was_at_bottom = output_window.viewport_at_bottom

M._hidden = true
M._toggling = true

pcall(vim.api.nvim_win_close, windows.input_win, false)
windows.input_win = nil

vim.schedule(function()
M._toggling = false
end)

output_window.focus_output(true)

if was_at_bottom then
vim.schedule(function()
require('opencode.ui.renderer').scroll_to_bottom(true)
end)
end
end

---Show the input window by recreating it
function M._show()
local windows = state.windows
if not windows or not windows.input_buf or not windows.output_win then
return
end

-- Don't recreate if already visible
if windows.input_win and vim.api.nvim_win_is_valid(windows.input_win) then
M._hidden = false
return
end

local output_window = require('opencode.ui.output_window')
local was_at_bottom = output_window.viewport_at_bottom

local output_win = windows.output_win
vim.api.nvim_set_current_win(output_win)

local input_position = config.ui.input_position or 'bottom'
vim.cmd((input_position == 'top' and 'aboveleft' or 'belowright') .. ' split')
local input_win = vim.api.nvim_get_current_win()

vim.api.nvim_win_set_buf(input_win, windows.input_buf)
windows.input_win = input_win

-- Re-apply window settings
M.setup(windows)

M._hidden = false

-- Focus the input window
M.focus_input()

if was_at_bottom then
vim.schedule(function()
require('opencode.ui.renderer').scroll_to_bottom(true)
end)
end
end

---Check if the input window is currently hidden
---@return boolean
function M.is_hidden()
return M._hidden
end

return M
12 changes: 8 additions & 4 deletions lua/opencode/ui/output_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -194,19 +194,23 @@ function M.setup_autocmds(windows, group)
group = group,
buffer = windows.output_buf,
callback = function()
vim.cmd('stopinsert')
local input_window = require('opencode.ui.input_window')
state.last_focused_opencode_window = 'output'
require('opencode.ui.input_window').refresh_placeholder(state.windows)
input_window.refresh_placeholder(state.windows)

vim.cmd('stopinsert')
end,
})

vim.api.nvim_create_autocmd('BufEnter', {
group = group,
buffer = windows.output_buf,
callback = function()
vim.cmd('stopinsert')
local input_window = require('opencode.ui.input_window')
state.last_focused_opencode_window = 'output'
require('opencode.ui.input_window').refresh_placeholder(state.windows)
input_window.refresh_placeholder(state.windows)

vim.cmd('stopinsert')
end,
})

Expand Down
31 changes: 21 additions & 10 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -215,13 +215,18 @@ end

---Auto-scroll to bottom if user was already at bottom
---Respects cursor position if user has scrolled up
function M.scroll_to_bottom()
---@param force? boolean If true, scroll regardless of current position
function M.scroll_to_bottom(force)
if not state.windows or not state.windows.output_buf or not state.windows.output_win then
return
end

if not vim.api.nvim_win_is_valid(state.windows.output_win) then
return
end

local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf)
if not ok then
if not ok or line_count == 0 then
return
end

Expand All @@ -233,18 +238,24 @@ function M.scroll_to_bottom()
trigger_on_data_rendered()

-- Determine if we should scroll to bottom
local should_scroll = false

-- Always scroll on initial render
if prev_line_count == 0 then
should_scroll = true
-- Scroll if user is at bottom (respects manual scroll position)
elseif output_window.viewport_at_bottom then
should_scroll = true
local should_scroll = force == true

if not should_scroll then
-- Always scroll on initial render
if prev_line_count == 0 then
should_scroll = true
-- Scroll if user is at bottom (respects manual scroll position)
elseif output_window.viewport_at_bottom then
should_scroll = true
end
end

if should_scroll then
vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })
-- Use zb to position the cursor line at the bottom of the visible window
vim.api.nvim_win_call(state.windows.output_win, function()
vim.cmd('normal! zb')
end)
output_window.viewport_at_bottom = true
else
-- User has scrolled up, don't scroll
Expand Down
11 changes: 10 additions & 1 deletion lua/opencode/ui/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,16 @@ end
function M.focus_input(opts)
opts = opts or {}
local windows = state.windows
if not windows or not windows.input_win then
if not windows then
return
end

if input_window.is_hidden() then
input_window._show()
return
end

if not windows.input_win then
return
end

Expand Down
5 changes: 0 additions & 5 deletions tests/unit/util_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,6 @@ describe('util.format_time', function()
end
end)

it('formats yesterday with same month date', function()
local result = util.format_time(yesterday)
assert.matches('^%d%d? %a%a%a %d%d?:%d%d [AP]M$', result)
end)

it('formats future date with full date', function()
local result = util.format_time(next_year)
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result)
Expand Down