Skip to content

Commit 69d6d6d

Browse files
committed
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
1 parent f128f20 commit 69d6d6d

7 files changed

Lines changed: 178 additions & 21 deletions

File tree

lua/opencode/api.lua

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

25+
function M.toggle_input()
26+
input_window.toggle()
27+
end
28+
2529
function M.open_input()
2630
return core.open({ new_session = false, focus = 'input', start_insert = true })
2731
end
@@ -51,7 +55,11 @@ function M.paste_image()
5155
end
5256

5357
M.toggle = Promise.async(function(new_session)
54-
local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output'
58+
-- When auto_hide input is enabled, always focus input; otherwise use last focused
59+
local focus = 'input' ---@cast focus 'input' | 'output'
60+
if not config.ui.input.auto_hide then
61+
focus = state.last_focused_opencode_window or 'input'
62+
end
5563
if state.windows == nil then
5664
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
5765
else
@@ -108,9 +116,7 @@ function M.select_history()
108116
end
109117

110118
function M.toggle_pane()
111-
return core.open({ new_session = false, focus = 'output' }):and_then(function()
112-
ui.toggle_pane()
113-
end)
119+
ui.toggle_pane()
114120
end
115121

116122
---@param from_snapshot_id? string
@@ -270,7 +276,12 @@ M.submit_input_prompt = Promise.async(function()
270276
ui.render_output(true)
271277
end
272278

273-
input_window.handle_submit()
279+
local message_sent = input_window.handle_submit()
280+
281+
-- Only hide input window if a message was actually sent (not slash commands, shell commands, etc.)
282+
if message_sent and config.ui.input.auto_hide and not input_window.is_hidden() then
283+
input_window._hide()
284+
end
274285
end)
275286

276287
function M.mention_file()
@@ -970,6 +981,11 @@ M.commands = {
970981
fn = M.toggle_zoom,
971982
},
972983

984+
toggle_input = {
985+
desc = 'Toggle input window visibility',
986+
fn = M.toggle_input,
987+
},
988+
973989
swap = {
974990
desc = 'Swap pane position left/right',
975991
fn = M.swap_position,

lua/opencode/config.lua

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ M.defaults = {
4949
['<C-c>'] = { 'cancel' },
5050
[']]'] = { 'next_message' },
5151
['[['] = { 'prev_message' },
52-
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
52+
['<tab>'] = { 'toggle_pane', mode = { 'n' } },
5353
['i'] = { 'focus_input' },
5454
['gr'] = { 'references', desc = 'Browse code references' },
55+
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
5556
['<leader>oS'] = { 'select_child_session' },
5657
['<leader>oD'] = { 'debug_message' },
5758
['<leader>oO'] = { 'debug_output' },
@@ -66,10 +67,11 @@ M.defaults = {
6667
['/'] = { 'slash_commands', mode = 'i' },
6768
['#'] = { 'context_items', mode = 'i' },
6869
['<M-v>'] = { 'paste_image', mode = 'i' },
69-
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
70+
['<tab>'] = { 'toggle_pane', mode = { 'n' } },
7071
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } },
7172
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } },
7273
['<M-m>'] = { 'switch_mode', mode = { 'n', 'i' } },
74+
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
7375
['gr'] = { 'references', desc = 'Browse code references' },
7476
['<leader>oS'] = { 'select_child_session' },
7577
['<leader>oD'] = { 'debug_message' },
@@ -130,6 +132,8 @@ M.defaults = {
130132
text = {
131133
wrap = false,
132134
},
135+
-- Auto-hide input window when prompt is submitted or focus switches to output window
136+
auto_hide = true,
133137
},
134138
completion = {
135139
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: 128 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,103 @@ 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+
-- Show: recreate the input window
404+
M._show()
405+
else
406+
-- Hide: close the input window
407+
M._hide()
408+
end
409+
end
410+
411+
---Hide the input window by closing it
412+
function M._hide()
413+
local windows = state.windows
414+
if not M.mounted(windows) then
415+
return
416+
end
417+
418+
local output_window = require('opencode.ui.output_window')
419+
local was_at_bottom = output_window.viewport_at_bottom
420+
421+
M._hidden = true
422+
M._toggling = true
423+
424+
-- Close the input window (but keep the buffer)
425+
pcall(vim.api.nvim_win_close, windows.input_win, false)
426+
windows.input_win = nil
427+
428+
-- Reset toggling flag after the WinClosed event has been processed
429+
vim.schedule(function()
430+
M._toggling = false
431+
end)
432+
433+
-- Focus output window
434+
output_window.focus_output(true)
435+
436+
if was_at_bottom then
437+
vim.schedule(function()
438+
require('opencode.ui.renderer').scroll_to_bottom()
439+
end)
440+
end
441+
end
442+
443+
---Show the input window by recreating it
444+
function M._show()
445+
local windows = state.windows
446+
if not windows or not windows.input_buf or not windows.output_win then
447+
return
448+
end
449+
450+
-- Don't recreate if already visible
451+
if windows.input_win and vim.api.nvim_win_is_valid(windows.input_win) then
452+
M._hidden = false
453+
return
454+
end
455+
456+
local output_window = require('opencode.ui.output_window')
457+
local was_at_bottom = output_window.viewport_at_bottom
458+
459+
-- Create a new split for the input window
460+
local output_win = windows.output_win
461+
vim.api.nvim_set_current_win(output_win)
462+
463+
local input_position = config.ui.input_position or 'bottom'
464+
vim.cmd((input_position == 'top' and 'aboveleft' or 'belowright') .. ' split')
465+
local input_win = vim.api.nvim_get_current_win()
466+
467+
-- Set the buffer
468+
vim.api.nvim_win_set_buf(input_win, windows.input_buf)
469+
windows.input_win = input_win
470+
471+
-- Re-apply window settings
472+
M.setup(windows)
473+
474+
M._hidden = false
475+
476+
-- Focus the input window
477+
M.focus_input()
478+
479+
if was_at_bottom then
480+
vim.schedule(function()
481+
require('opencode.ui.renderer').scroll_to_bottom()
482+
end)
483+
end
484+
end
485+
486+
---Check if the input window is currently hidden
487+
---@return boolean
488+
function M.is_hidden()
489+
return M._hidden
490+
end
491+
368492
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/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)