Skip to content

Commit 009c07d

Browse files
authored
feat(ui): add dynamic input window toggle (#156)
* feat(ui): add toggle input window functionality - introduce toggle_input function to manage input window visibility - add keybindings for toggling input window - implement auto-hide behavior for input window after message submission - adjust focus handling to include hidden input window Closes #123 * refactor(config): disable auto_hide by default * refactor(ui): remove unnecessary comments * feat(ui): enhance auto-scroll functionality in input and output windows - adjust scroll_to_bottom function to include a force parameter - ensure auto-scrolling respects user scroll position unless forced
1 parent f2d3d9e commit 009c07d

8 files changed

Lines changed: 192 additions & 31 deletions

File tree

lua/opencode/api.lua

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ function M.toggle_zoom()
2323
require('opencode.ui.ui').toggle_zoom()
2424
end
2525

26+
function M.toggle_input()
27+
input_window.toggle()
28+
end
29+
2630
function M.open_input()
2731
return core.open({ new_session = false, focus = 'input', start_insert = true })
2832
end
@@ -52,7 +56,11 @@ function M.paste_image()
5256
end
5357

5458
M.toggle = Promise.async(function(new_session)
55-
local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output'
59+
-- When auto_hide input is enabled, always focus input; otherwise use last focused
60+
local focus = 'input' ---@cast focus 'input' | 'output'
61+
if not config.ui.input.auto_hide then
62+
focus = state.last_focused_opencode_window or 'input'
63+
end
5664
if state.windows == nil then
5765
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
5866
else
@@ -144,9 +152,7 @@ function M.quick_chat(message, range)
144152
end
145153

146154
function M.toggle_pane()
147-
return core.open({ new_session = false, focus = 'output' }):and_then(function()
148-
ui.toggle_pane()
149-
end)
155+
ui.toggle_pane()
150156
end
151157

152158
---@param from_snapshot_id? string
@@ -306,7 +312,12 @@ M.submit_input_prompt = Promise.async(function()
306312
ui.render_output(true)
307313
end
308314

309-
input_window.handle_submit()
315+
local message_sent = input_window.handle_submit()
316+
317+
-- Only hide input window if a message was actually sent (not slash commands, shell commands, etc.)
318+
if message_sent and config.ui.input.auto_hide and not input_window.is_hidden() then
319+
input_window._hide()
320+
end
310321
end)
311322

312323
function M.mention_file()
@@ -1006,6 +1017,11 @@ M.commands = {
10061017
fn = M.toggle_zoom,
10071018
},
10081019

1020+
toggle_input = {
1021+
desc = 'Toggle input window visibility',
1022+
fn = M.toggle_input,
1023+
},
1024+
10091025
quick_chat = {
10101026
desc = 'Quick chat with current buffer or visual selection',
10111027
fn = M.quick_chat,

lua/opencode/config.lua

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ M.defaults = {
5050
['<C-c>'] = { 'cancel' },
5151
[']]'] = { 'next_message' },
5252
['[['] = { 'prev_message' },
53-
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
53+
['<tab>'] = { 'toggle_pane', mode = { 'n' } },
5454
['i'] = { 'focus_input' },
5555
['gr'] = { 'references', desc = 'Browse code references' },
56+
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
5657
['<leader>oS'] = { 'select_child_session' },
5758
['<leader>oD'] = { 'debug_message' },
5859
['<leader>oO'] = { 'debug_output' },
@@ -67,10 +68,11 @@ M.defaults = {
6768
['/'] = { 'slash_commands', mode = 'i' },
6869
['#'] = { 'context_items', mode = 'i' },
6970
['<M-v>'] = { 'paste_image', mode = 'i' },
70-
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
71+
['<tab>'] = { 'toggle_pane', mode = { 'n' } },
7172
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } },
7273
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } },
7374
['<M-m>'] = { 'switch_mode', mode = { 'n', 'i' } },
75+
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
7476
['gr'] = { 'references', desc = 'Browse code references' },
7577
['<leader>oS'] = { 'select_child_session' },
7678
['<leader>oD'] = { 'debug_message' },
@@ -134,6 +136,8 @@ M.defaults = {
134136
text = {
135137
wrap = false,
136138
},
139+
-- Auto-hide input window when prompt is submitted or focus switches to output window
140+
auto_hide = false,
137141
},
138142
completion = {
139143
file_sources = {

lua/opencode/ui/autocmds.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ function M.setup_autocmds(windows)
1313
group = group,
1414
pattern = table.concat(wins, ','),
1515
callback = function(opts)
16+
-- Don't close everything if we're just toggling the input window
17+
if input_window._toggling then
18+
return
19+
end
20+
1621
local closed_win = tonumber(opts.match)
1722
if vim.tbl_contains(wins, closed_win) then
1823
vim.schedule(function()

lua/opencode/ui/input_window.lua

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ local state = require('opencode.state')
22
local config = require('opencode.config')
33
local M = {}
44

5+
-- Track hidden state
6+
M._hidden = false
7+
-- Flag to prevent WinClosed autocmd from closing all windows during toggle
8+
M._toggling = false
9+
510
function M.create_buf()
611
local input_buf = vim.api.nvim_create_buf(false, true)
712
vim.api.nvim_set_option_value('filetype', 'opencode', { buf = input_buf })
@@ -48,10 +53,12 @@ function M.close()
4853
pcall(vim.api.nvim_buf_delete, state.windows.input_buf, { force = true })
4954
end
5055

56+
---Handle submit action from input window
57+
---@return boolean true if a message was sent to the AI, false otherwise
5158
function M.handle_submit()
5259
local windows = state.windows
5360
if not windows or not M.mounted(windows) then
54-
return
61+
return false
5562
end
5663
---@cast windows { input_buf: integer }
5764

@@ -63,21 +70,22 @@ function M.handle_submit()
6370
})
6471

6572
if input_content == '' then
66-
return
73+
return false
6774
end
6875

6976
if input_content:match('^!') then
7077
M._execute_shell_command(input_content:sub(2))
71-
return
78+
return false
7279
end
7380

7481
local key = config.get_key_for_function('input_window', 'slash_commands') or '/'
7582
if input_content:match('^' .. key) then
7683
M._execute_slash_command(input_content)
77-
return
84+
return false
7885
end
7986

8087
require('opencode.core').send_message(input_content)
88+
return true
8189
end
8290

8391
M._execute_shell_command = function(command)
@@ -267,6 +275,11 @@ function M.recover_input(windows)
267275
end
268276

269277
function M.focus_input()
278+
if M._hidden then
279+
M._show()
280+
return
281+
end
282+
270283
if not M.mounted() then
271284
return
272285
end
@@ -350,6 +363,18 @@ function M.setup_autocmds(windows, group)
350363
end,
351364
})
352365

366+
vim.api.nvim_create_autocmd('WinLeave', {
367+
group = group,
368+
buffer = windows.input_buf,
369+
callback = function()
370+
-- Auto-hide input window when auto_hide is enabled and focus leaves
371+
-- Don't hide if displaying a route (slash command output like /help)
372+
if config.ui.input.auto_hide and not M.is_hidden() and not state.display_route then
373+
M._hide()
374+
end
375+
end,
376+
})
377+
353378
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
354379
buffer = windows.input_buf,
355380
callback = function()
@@ -365,4 +390,96 @@ function M.setup_autocmds(windows, group)
365390
end)
366391
end
367392

393+
---Toggle the input window visibility (hide/show)
394+
---When hidden, the input window is closed entirely
395+
---When shown, the input window is recreated
396+
function M.toggle()
397+
local windows = state.windows
398+
if not windows then
399+
return
400+
end
401+
402+
if M._hidden then
403+
M._show()
404+
else
405+
M._hide()
406+
end
407+
end
408+
409+
---Hide the input window by closing it
410+
function M._hide()
411+
local windows = state.windows
412+
if not M.mounted(windows) then
413+
return
414+
end
415+
416+
local output_window = require('opencode.ui.output_window')
417+
local was_at_bottom = output_window.viewport_at_bottom
418+
419+
M._hidden = true
420+
M._toggling = true
421+
422+
pcall(vim.api.nvim_win_close, windows.input_win, false)
423+
windows.input_win = nil
424+
425+
vim.schedule(function()
426+
M._toggling = false
427+
end)
428+
429+
output_window.focus_output(true)
430+
431+
if was_at_bottom then
432+
vim.schedule(function()
433+
require('opencode.ui.renderer').scroll_to_bottom(true)
434+
end)
435+
end
436+
end
437+
438+
---Show the input window by recreating it
439+
function M._show()
440+
local windows = state.windows
441+
if not windows or not windows.input_buf or not windows.output_win then
442+
return
443+
end
444+
445+
-- Don't recreate if already visible
446+
if windows.input_win and vim.api.nvim_win_is_valid(windows.input_win) then
447+
M._hidden = false
448+
return
449+
end
450+
451+
local output_window = require('opencode.ui.output_window')
452+
local was_at_bottom = output_window.viewport_at_bottom
453+
454+
local output_win = windows.output_win
455+
vim.api.nvim_set_current_win(output_win)
456+
457+
local input_position = config.ui.input_position or 'bottom'
458+
vim.cmd((input_position == 'top' and 'aboveleft' or 'belowright') .. ' split')
459+
local input_win = vim.api.nvim_get_current_win()
460+
461+
vim.api.nvim_win_set_buf(input_win, windows.input_buf)
462+
windows.input_win = input_win
463+
464+
-- Re-apply window settings
465+
M.setup(windows)
466+
467+
M._hidden = false
468+
469+
-- Focus the input window
470+
M.focus_input()
471+
472+
if was_at_bottom then
473+
vim.schedule(function()
474+
require('opencode.ui.renderer').scroll_to_bottom(true)
475+
end)
476+
end
477+
end
478+
479+
---Check if the input window is currently hidden
480+
---@return boolean
481+
function M.is_hidden()
482+
return M._hidden
483+
end
484+
368485
return M

lua/opencode/ui/output_window.lua

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,19 +194,23 @@ function M.setup_autocmds(windows, group)
194194
group = group,
195195
buffer = windows.output_buf,
196196
callback = function()
197-
vim.cmd('stopinsert')
197+
local input_window = require('opencode.ui.input_window')
198198
state.last_focused_opencode_window = 'output'
199-
require('opencode.ui.input_window').refresh_placeholder(state.windows)
199+
input_window.refresh_placeholder(state.windows)
200+
201+
vim.cmd('stopinsert')
200202
end,
201203
})
202204

203205
vim.api.nvim_create_autocmd('BufEnter', {
204206
group = group,
205207
buffer = windows.output_buf,
206208
callback = function()
207-
vim.cmd('stopinsert')
209+
local input_window = require('opencode.ui.input_window')
208210
state.last_focused_opencode_window = 'output'
209-
require('opencode.ui.input_window').refresh_placeholder(state.windows)
211+
input_window.refresh_placeholder(state.windows)
212+
213+
vim.cmd('stopinsert')
210214
end,
211215
})
212216

lua/opencode/ui/renderer.lua

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,18 @@ end
215215

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

224+
if not vim.api.nvim_win_is_valid(state.windows.output_win) then
225+
return
226+
end
227+
223228
local ok, line_count = pcall(vim.api.nvim_buf_line_count, state.windows.output_buf)
224-
if not ok then
229+
if not ok or line_count == 0 then
225230
return
226231
end
227232

@@ -233,18 +238,24 @@ function M.scroll_to_bottom()
233238
trigger_on_data_rendered()
234239

235240
-- Determine if we should scroll to bottom
236-
local should_scroll = false
237-
238-
-- Always scroll on initial render
239-
if prev_line_count == 0 then
240-
should_scroll = true
241-
-- Scroll if user is at bottom (respects manual scroll position)
242-
elseif output_window.viewport_at_bottom then
243-
should_scroll = true
241+
local should_scroll = force == true
242+
243+
if not should_scroll then
244+
-- Always scroll on initial render
245+
if prev_line_count == 0 then
246+
should_scroll = true
247+
-- Scroll if user is at bottom (respects manual scroll position)
248+
elseif output_window.viewport_at_bottom then
249+
should_scroll = true
250+
end
244251
end
245252

246253
if should_scroll then
247254
vim.api.nvim_win_set_cursor(state.windows.output_win, { line_count, 0 })
255+
-- Use zb to position the cursor line at the bottom of the visible window
256+
vim.api.nvim_win_call(state.windows.output_win, function()
257+
vim.cmd('normal! zb')
258+
end)
248259
output_window.viewport_at_bottom = true
249260
else
250261
-- User has scrolled up, don't scroll

lua/opencode/ui/ui.lua

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,16 @@ end
124124
function M.focus_input(opts)
125125
opts = opts or {}
126126
local windows = state.windows
127-
if not windows or not windows.input_win then
127+
if not windows then
128+
return
129+
end
130+
131+
if input_window.is_hidden() then
132+
input_window._show()
133+
return
134+
end
135+
136+
if not windows.input_win then
128137
return
129138
end
130139

tests/unit/util_spec.lua

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,6 @@ describe('util.format_time', function()
161161
end
162162
end)
163163

164-
it('formats yesterday with same month date', function()
165-
local result = util.format_time(yesterday)
166-
assert.matches('^%d%d? %a%a%a %d%d?:%d%d [AP]M$', result)
167-
end)
168-
169164
it('formats future date with full date', function()
170165
local result = util.format_time(next_year)
171166
assert.matches('^%d%d? %a%a%a %d%d%d%d %d%d?:%d%d [AP]M$', result)

0 commit comments

Comments
 (0)