Skip to content

Commit 4e2e884

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

8 files changed

Lines changed: 232 additions & 29 deletions

File tree

lua/opencode/keymap.lua

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
local M = {}
22
local commands = require('opencode.commands')
33

4+
local function normalize_lhs(lhs)
5+
return vim.api.nvim_replace_termcodes(lhs, true, true, true)
6+
end
7+
48
local function is_completion_visible()
59
return require('opencode.ui.completion').is_completion_visible()
610
end
@@ -44,7 +48,8 @@ end
4448
---@param keymap_config table The keymap configuration table
4549
---@param default_modes table Default modes for these keymaps
4650
---@param base_opts table Base options to use for all keymaps
47-
local function process_keymap_entry(keymap_config, default_modes, base_opts)
51+
---@param preserve_existing? boolean
52+
local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing)
4853
local command_defs = commands.get_commands()
4954

5055
for key_binding, config_entry in pairs(keymap_config) do
@@ -56,10 +61,26 @@ local function process_keymap_entry(keymap_config, default_modes, base_opts)
5661
local callback = resolve_callback(func_name, func_args)
5762

5863
local modes = config_entry.mode or default_modes
64+
if preserve_existing and base_opts.buffer then
65+
local missing_modes = {}
66+
for _, mode in ipairs(type(modes) == 'table' and modes or { modes }) do
67+
local exists = false
68+
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(base_opts.buffer, mode)) do
69+
if normalize_lhs(mapping.lhs) == normalize_lhs(key_binding) then
70+
exists = true
71+
break
72+
end
73+
end
74+
if not exists then
75+
table.insert(missing_modes, mode)
76+
end
77+
end
78+
modes = missing_modes
79+
end
5980
local opts = vim.tbl_deep_extend('force', {}, base_opts)
6081
opts.desc = config_entry.desc or vim.tbl_get(command_defs, func_name, 'desc') or ''
6182

62-
if callback then
83+
if callback and #modes > 0 then
6384
if config_entry.defer_to_completion then
6485
callback = wrap_with_completion_check(key_binding, callback)
6586
end
@@ -78,12 +99,13 @@ end
7899

79100
---@param keymap_config table Window keymap configuration
80101
---@param buf_id integer Buffer ID to set keymaps for
81-
function M.setup_window_keymaps(keymap_config, buf_id)
102+
---@param preserve_existing? boolean
103+
function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing)
82104
if not vim.api.nvim_buf_is_valid(buf_id) then
83105
return
84106
end
85107

86-
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id })
108+
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing)
87109
end
88110

89111
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 + 3 - 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: 85 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,47 @@ 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 1new')
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 col = vim.api.nvim_win_get_width(anchor_win) - 3
212+
local anchor = vim.fn.screenpos(anchor_win, 1, col + 1)
213+
assert.equals(vim.o.lines - 2, anchor.row)
214+
215+
local input = open_input(0, col)
216+
local position = vim.api.nvim_win_get_position(input.win)
217+
local window_config = vim.api.nvim_win_get_config(input.win)
218+
219+
assert.is_true(
220+
position[2] + window_config.width + 2 <= vim.o.columns,
221+
vim.inspect({ position = position, config = window_config, columns = vim.o.columns, lines = vim.o.lines })
222+
)
223+
assert.is_true(
224+
position[1] + window_config.height + 2 <= vim.o.lines,
225+
vim.inspect({ position = position, config = window_config, columns = vim.o.columns, lines = vim.o.lines })
226+
)
227+
input.close()
228+
vim.cmd('only')
229+
end)
230+
231+
it('removes its WinClosed watcher after closing', function()
232+
local before = #vim.api.nvim_get_autocmds({ event = 'WinClosed' })
233+
234+
for _ = 1, 5 do
235+
local input = open_input(0, 0)
236+
input.close()
237+
end
238+
239+
assert.equals(before, #vim.api.nvim_get_autocmds({ event = 'WinClosed' }))
240+
end)
241+
188242
it('resizes after multiline text changes outside insert mode', function()
189243
local input = open_input(0, 0)
190244
local lines = {
@@ -230,6 +284,34 @@ describe('inline_input', function()
230284
assert.equals(1, cancelled)
231285
end)
232286

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

tests/unit/keymap_spec.lua

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,27 @@ describe('opencode.keymap', function()
221221

222222
mock_commands.execute_parsed_intent = original_execute_parsed_intent
223223
end)
224+
225+
it('preserves existing mappings across equivalent lhs notation', function()
226+
local bufnr = vim.api.nvim_create_buf(false, true)
227+
local original_mapleader = vim.g.mapleader
228+
vim.g.mapleader = '\\'
229+
230+
for _, lhs in ipairs({ '<Esc>', '<Tab>', '<C-C>', '\\x' }) do
231+
original_keymap_set('n', lhs, function() end, { buffer = bufnr })
232+
end
233+
234+
keymap.setup_window_keymaps({
235+
['<esc>'] = { function() end },
236+
['<tab>'] = { function() end },
237+
['<C-c>'] = { function() end },
238+
['<leader>x'] = { function() end },
239+
}, bufnr, true)
240+
241+
assert.equal(0, #set_keymaps)
242+
vim.g.mapleader = original_mapleader
243+
vim.api.nvim_buf_delete(bufnr, { force = true })
244+
end)
224245
end)
225246

226247
describe('defer_to_completion', function()

0 commit comments

Comments
 (0)