diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index f57957c6..6285e58c 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -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 @@ -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 @@ -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 @@ -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() @@ -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, diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 7a897862..4282d982 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -50,9 +50,10 @@ M.defaults = { [''] = { 'cancel' }, [']]'] = { 'next_message' }, ['[['] = { 'prev_message' }, - [''] = { 'toggle_pane', mode = { 'n', 'i' } }, + [''] = { 'toggle_pane', mode = { 'n' } }, ['i'] = { 'focus_input' }, ['gr'] = { 'references', desc = 'Browse code references' }, + [''] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' }, ['oS'] = { 'select_child_session' }, ['oD'] = { 'debug_message' }, ['oO'] = { 'debug_output' }, @@ -67,10 +68,11 @@ M.defaults = { ['/'] = { 'slash_commands', mode = 'i' }, ['#'] = { 'context_items', mode = 'i' }, [''] = { 'paste_image', mode = 'i' }, - [''] = { 'toggle_pane', mode = { 'n', 'i' } }, + [''] = { 'toggle_pane', mode = { 'n' } }, [''] = { 'prev_prompt_history', mode = { 'n', 'i' } }, [''] = { 'next_prompt_history', mode = { 'n', 'i' } }, [''] = { 'switch_mode', mode = { 'n', 'i' } }, + [''] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' }, ['gr'] = { 'references', desc = 'Browse code references' }, ['oS'] = { 'select_child_session' }, ['oD'] = { 'debug_message' }, @@ -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 = { diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 6b70d30a..a3301654 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -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() diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 550a8217..0e589cba 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -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 }) @@ -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 } @@ -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) @@ -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 @@ -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() @@ -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 diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 6da3ff8d..3f4b2bfa 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -194,9 +194,11 @@ 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, }) @@ -204,9 +206,11 @@ 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, }) diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 832e7145..cf718b02 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -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 @@ -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 diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index d025a2b7..c160840c 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -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 diff --git a/tests/unit/util_spec.lua b/tests/unit/util_spec.lua index 4d63af39..e44dc214 100644 --- a/tests/unit/util_spec.lua +++ b/tests/unit/util_spec.lua @@ -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)