Skip to content

Commit 487a6d2

Browse files
committed
fix(ui): keep inline input within panel lifecycle
1 parent 4d00462 commit 487a6d2

7 files changed

Lines changed: 198 additions & 23 deletions

File tree

lua/opencode/keymap.lua

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ end
4444
---@param keymap_config table The keymap configuration table
4545
---@param default_modes table Default modes for these keymaps
4646
---@param base_opts table Base options to use for all keymaps
47-
local function process_keymap_entry(keymap_config, default_modes, base_opts)
47+
---@param preserve_existing? boolean
48+
local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing)
4849
local command_defs = commands.get_commands()
4950

5051
for key_binding, config_entry in pairs(keymap_config) do
@@ -56,10 +57,26 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts)
5657
local callback = resolve_callback(func_name, func_args)
5758

5859
local modes = config_entry.mode or default_modes
60+
if preserve_existing and base_opts.buffer then
61+
local missing_modes = {}
62+
for _, mode in ipairs(type(modes) == 'table' and modes or { modes }) do
63+
local exists = false
64+
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(base_opts.buffer, mode)) do
65+
if mapping.lhs == key_binding then
66+
exists = true
67+
break
68+
end
69+
end
70+
if not exists then
71+
table.insert(missing_modes, mode)
72+
end
73+
end
74+
modes = missing_modes
75+
end
5976
local opts = vim.tbl_deep_extend('force', {}, base_opts)
6077
opts.desc = config_entry.desc or vim.tbl_get(command_defs, func_name, 'desc') or ''
6178

62-
if callback then
79+
if callback and #modes > 0 then
6380
if config_entry.defer_to_completion then
6481
callback = wrap_with_completion_check(key_binding, callback)
6582
end
@@ -78,12 +95,13 @@ end
7895

7996
---@param keymap_config table Window keymap configuration
8097
---@param buf_id integer Buffer ID to set keymaps for
81-
function M.setup_window_keymaps(keymap_config, buf_id)
98+
---@param preserve_existing? boolean
99+
function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing)
82100
if not vim.api.nvim_buf_is_valid(buf_id) then
83101
return
84102
end
85103

86-
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id })
104+
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing)
87105
end
88106

89107
return M

lua/opencode/ui/inline_input.lua

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ local M = {}
1818
function M.open(opts)
1919
local anchor = vim.fn.screenpos(opts.win, opts.row + 1, opts.col + 1)
2020
local width = math.max(1, math.min(50, vim.o.columns - anchor.col - 1))
21-
local max_height = math.max(1, vim.o.lines - anchor.row - 2)
21+
local col_shift = math.max(0, anchor.col + width + 1 - vim.o.columns)
22+
local row_shift = math.max(0, anchor.row + 2 - vim.o.lines)
23+
local max_height = math.max(1, vim.o.lines - anchor.row + row_shift - 2)
2224
local initial_lines = opts.initial_text and vim.split(opts.initial_text, '\n', { plain = true }) or nil
2325

2426
local buf = vim.api.nvim_create_buf(false, true)
@@ -34,6 +36,8 @@ function M.open(opts)
3436
relative = 'win',
3537
win = opts.win,
3638
bufpos = { opts.row, opts.col },
39+
row = 1 - row_shift,
40+
col = -col_shift,
3741
width = width,
3842
height = 1,
3943
style = 'minimal',
@@ -57,11 +61,20 @@ function M.open(opts)
5761
resize_height()
5862

5963
local closed = false
64+
local win_closed_autocmd
65+
local function delete_win_closed_autocmd()
66+
if win_closed_autocmd then
67+
pcall(vim.api.nvim_del_autocmd, win_closed_autocmd)
68+
win_closed_autocmd = nil
69+
end
70+
end
71+
6072
local function close()
6173
if closed then
6274
return
6375
end
6476
closed = true
77+
delete_win_closed_autocmd()
6578
if vim.api.nvim_win_is_valid(win) then
6679
vim.api.nvim_win_close(win, true)
6780
end
@@ -106,13 +119,18 @@ function M.open(opts)
106119
end,
107120
})
108121

109-
vim.api.nvim_create_autocmd('WinClosed', {
110-
pattern = tostring(win),
111-
callback = function()
122+
win_closed_autocmd = vim.api.nvim_create_autocmd('WinClosed', {
123+
pattern = { tostring(opts.win), tostring(win) },
124+
callback = function(event)
112125
if closed then
113126
return
114127
end
128+
if tonumber(event.match) == opts.win then
129+
cancel_with_draft()
130+
return
131+
end
115132
closed = true
133+
delete_win_closed_autocmd()
116134
if vim.api.nvim_win_is_valid(opts.win) then
117135
vim.api.nvim_set_current_win(opts.win)
118136
end

lua/opencode/ui/output_window.lua

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,6 @@ function M.setup(windows)
300300
M.update_dimensions(windows)
301301
M.reset_scroll_tracking(windows.output_win)
302302
M._last_visible_bottom_by_win[windows.output_win] = M.get_visible_bottom_line(windows.output_win)
303-
M.setup_keymaps(windows)
304303
end
305304

306305
---@param windows OpencodeWindowState?
@@ -681,18 +680,30 @@ function M.close()
681680
end
682681

683682
---@param windows OpencodeWindowState
684-
function M.setup_keymaps(windows)
683+
---@param preserve_existing? boolean
684+
function M.setup_keymaps(windows, preserve_existing)
685685
local keymap = require('opencode.keymap')
686-
keymap.setup_window_keymaps(config.keymap.output_window, windows.output_buf)
686+
keymap.setup_window_keymaps(config.keymap.output_window, windows.output_buf, preserve_existing)
687687

688688
-- When lazy-render is active, gg only reaches the top of rendered content.
689689
-- Load all messages first so gg reaches the true start of history.
690-
vim.keymap.set('n', 'gg', function()
691-
local renderer = require('opencode.ui.renderer')
692-
renderer.load_all_messages()
693-
pcall(vim.cmd, [[noau normal! m']])
694-
vim.api.nvim_win_set_cursor(0, { 1, 0 })
695-
end, { buffer = windows.output_buf })
690+
local has_gg = false
691+
if preserve_existing then
692+
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(windows.output_buf, 'n')) do
693+
if mapping.lhs == 'gg' then
694+
has_gg = true
695+
break
696+
end
697+
end
698+
end
699+
if not has_gg then
700+
vim.keymap.set('n', 'gg', function()
701+
local renderer = require('opencode.ui.renderer')
702+
renderer.load_all_messages()
703+
pcall(vim.cmd, [[noau normal! m']])
704+
vim.api.nvim_win_set_cursor(0, { 1, 0 })
705+
end, { buffer = windows.output_buf })
706+
end
696707
end
697708

698709
---@param windows OpencodeWindowState

lua/opencode/ui/question_window.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ function M.show_question(question_request)
222222
end
223223

224224
---@return boolean
225-
function M.restore_active_question_ui()
225+
local function restore_active_question_ui()
226226
local question = M._current_question
227227
if
228228
not question
@@ -246,7 +246,7 @@ function M.restore_pending_question(session_id)
246246

247247
if M.has_question() and session_scope.belongs_to_active_session(M._current_question) then
248248
if not is_resolved_question_request(M._current_question) then
249-
M.restore_active_question_ui()
249+
restore_active_question_ui()
250250
return Promise.new():resolve(nil)
251251
end
252252

lua/opencode/ui/ui.lua

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ function M.restore_hidden_windows()
246246

247247
input_window.setup(windows)
248248
output_window.setup(windows)
249+
output_window.setup_keymaps(windows, true)
249250
footer.setup(windows)
250251
if state.api_client and type(state.api_client.list_providers) == 'function' then
251252
topbar.setup()
@@ -281,7 +282,6 @@ function M.restore_hidden_windows()
281282
end)
282283

283284
require('opencode.ui.contextual_actions').setup_contextual_actions(windows)
284-
require('opencode.ui.question_window').restore_active_question_ui()
285285

286286
return true
287287
end
@@ -400,6 +400,7 @@ function M.create_windows()
400400

401401
input_window.setup(windows)
402402
output_window.setup(windows)
403+
output_window.setup_keymaps(windows)
403404
footer.setup(windows)
404405
topbar.setup()
405406

tests/unit/inline_input_spec.lua

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('inline_input', function()
2323

2424
after_each(function()
2525
if vim.api.nvim_win_is_valid(anchor_win) then
26-
vim.api.nvim_win_close(anchor_win, true)
26+
pcall(vim.api.nvim_win_close, anchor_win, true)
2727
end
2828
if vim.api.nvim_buf_is_valid(anchor_buf) then
2929
vim.api.nvim_buf_delete(anchor_buf, { force = true })
@@ -45,6 +45,17 @@ describe('inline_input', function()
4545
return input
4646
end
4747

48+
local function use_regular_anchor()
49+
vim.api.nvim_win_close(anchor_win, true)
50+
anchor_win = vim.api.nvim_get_current_win()
51+
vim.api.nvim_win_set_buf(anchor_win, anchor_buf)
52+
vim.wo[anchor_win].wrap = false
53+
vim.wo[anchor_win].signcolumn = 'no'
54+
vim.wo[anchor_win].foldcolumn = '0'
55+
vim.wo[anchor_win].number = false
56+
vim.wo[anchor_win].relativenumber = false
57+
end
58+
4859
local function change_text(input, text, expected_height)
4960
vim.api.nvim_buf_set_lines(input.buf, 0, 1, false, { text })
5061
vim.api.nvim_exec_autocmds('TextChangedI', { buffer = input.buf, modeline = false })
@@ -162,13 +173,15 @@ describe('inline_input', function()
162173
end)
163174

164175
it('shrinks its opening width before the right border reaches the editor edge', function()
176+
use_regular_anchor()
165177
local col = vim.api.nvim_win_get_width(anchor_win) - 2
166178
local input = open_input(0, col)
167179
local anchor = vim.fn.screenpos(anchor_win, 1, col + 1)
168180
local width = vim.api.nvim_win_get_config(input.win).width
181+
local position = vim.api.nvim_win_get_position(input.win)
169182

170-
assert.equals(math.min(50, vim.o.columns - anchor.col - 1), width)
171-
assert.is_true(anchor.col + width + 1 <= vim.o.columns)
183+
assert.equals(math.max(1, math.min(50, vim.o.columns - anchor.col - 1)), width)
184+
assert.is_true(position[2] + width + 2 <= vim.o.columns)
172185
input.close()
173186
end)
174187

@@ -185,6 +198,43 @@ describe('inline_input', function()
185198
input.close()
186199
end)
187200

201+
it('keeps its rounded border inside the editor at the bottom-right edge', function()
202+
vim.api.nvim_win_close(anchor_win, true)
203+
vim.cmd('botright 10vnew')
204+
vim.cmd('botright 2new')
205+
anchor_win = vim.api.nvim_get_current_win()
206+
vim.api.nvim_win_set_buf(anchor_win, anchor_buf)
207+
vim.wo[anchor_win].wrap = false
208+
vim.wo[anchor_win].signcolumn = 'no'
209+
vim.wo[anchor_win].foldcolumn = '0'
210+
211+
local input = open_input(0, vim.api.nvim_win_get_width(anchor_win) - 3)
212+
local position = vim.api.nvim_win_get_position(input.win)
213+
local window_config = vim.api.nvim_win_get_config(input.win)
214+
215+
assert.is_true(
216+
position[2] + window_config.width + 2 <= vim.o.columns,
217+
vim.inspect({ position = position, config = window_config, columns = vim.o.columns, lines = vim.o.lines })
218+
)
219+
assert.is_true(
220+
position[1] + window_config.height + 2 <= vim.o.lines,
221+
vim.inspect({ position = position, config = window_config, columns = vim.o.columns, lines = vim.o.lines })
222+
)
223+
input.close()
224+
vim.cmd('only')
225+
end)
226+
227+
it('removes its WinClosed watcher after closing', function()
228+
local before = #vim.api.nvim_get_autocmds({ event = 'WinClosed' })
229+
230+
for _ = 1, 5 do
231+
local input = open_input(0, 0)
232+
input.close()
233+
end
234+
235+
assert.equals(before, #vim.api.nvim_get_autocmds({ event = 'WinClosed' }))
236+
end)
237+
188238
it('resizes after multiline text changes outside insert mode', function()
189239
local input = open_input(0, 0)
190240
local lines = {
@@ -230,6 +280,34 @@ describe('inline_input', function()
230280
assert.equals(1, cancelled)
231281
end)
232282

283+
it('returns the draft and closes when its anchor window closes', function()
284+
local draft
285+
local cancelled = 0
286+
local input = inline_input.open({
287+
win = anchor_win,
288+
row = 0,
289+
col = 0,
290+
on_submit = function() end,
291+
on_cancel = function()
292+
cancelled = cancelled + 1
293+
end,
294+
on_leave = function(text)
295+
draft = text
296+
end,
297+
})
298+
assert.is_true(vim.wait(50, function()
299+
return vim.api.nvim_get_current_win() == input.win
300+
end))
301+
302+
local lines = { 'first line', 'second line' }
303+
vim.api.nvim_buf_set_lines(input.buf, 0, -1, false, lines)
304+
vim.api.nvim_win_close(anchor_win, true)
305+
306+
assert.is_false(vim.api.nvim_win_is_valid(input.win))
307+
assert.equals(table.concat(lines, '\n'), draft)
308+
assert.equals(1, cancelled)
309+
end)
310+
233311
it('submits every character from a wrapped mixed-language line', function()
234312
local submitted
235313
local text = 'English 中文 mixed ' .. string.rep('长文本', 40)

tests/unit/persist_state_spec.lua

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,21 @@ describe('persist_state', function()
353353
})
354354
require('opencode.ui.renderer.flush').flush()
355355

356+
question_window._dialog:set_selection(2)
357+
question_window._dialog:select()
358+
assert.is_true(vim.wait(100, function()
359+
return question_window._inline_input ~= nil
360+
end))
361+
local inline_win = question_window._inline_input.win
362+
local draft_lines = { 'first line', 'second line' }
363+
vim.api.nvim_buf_set_lines(question_window._inline_input.buf, 0, -1, false, draft_lines)
364+
356365
toggle_wait('hidden')
366+
367+
assert.is_false(vim.api.nvim_win_is_valid(inline_win))
368+
assert.is_nil(question_window._inline_input)
369+
assert.equals(table.concat(draft_lines, '\n'), question_window._other_input_drafts[1])
370+
357371
toggle_wait('visible')
358372

359373
local enter_mapping
@@ -365,9 +379,44 @@ describe('persist_state', function()
365379
end
366380

367381
assert.equals('Dialog: select option', enter_mapping and enter_mapping.desc)
382+
assert.equals(2, question_window._dialog:get_selection())
383+
384+
question_window._dialog:select()
385+
assert.is_true(vim.wait(100, function()
386+
return question_window._inline_input ~= nil
387+
end))
388+
assert.are.same(draft_lines, vim.api.nvim_buf_get_lines(question_window._inline_input.buf, 0, -1, false))
368389
question_window.clear_question()
369390
end)
370391

392+
it('restores missing base mappings without replacing preserved mappings', function()
393+
setup_ui()
394+
config.keymap.output_window.a = { function() end, desc = 'Base A' }
395+
create_code_file()
396+
toggle_wait('visible')
397+
398+
local output_buf = state.windows.output_buf
399+
local contextual_actions = require('opencode.ui.contextual_actions')
400+
contextual_actions.show_contextual_actions_menu(output_buf, {
401+
{ key = 'a', text = 'Temporary A', display_line = 0 },
402+
}, vim.api.nvim_create_namespace('persist-state-mapping-test'))
403+
404+
local function mapping_description(lhs)
405+
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(output_buf, 'n')) do
406+
if mapping.lhs == lhs then
407+
return mapping.desc
408+
end
409+
end
410+
end
411+
412+
assert.equals('Temporary A', mapping_description('a'))
413+
toggle_wait('hidden')
414+
assert.is_nil(mapping_description('a'))
415+
416+
toggle_wait('visible')
417+
assert.equals('Base A', mapping_description('a'))
418+
end)
419+
371420
it('fully closes when persist_state=false', function()
372421
setup_ui({ persist_state = false })
373422
create_code_file()

0 commit comments

Comments
 (0)