Skip to content

Commit bafc8e7

Browse files
authored
feat(ui): add <C-p>/<C-n> history navigation to question dialog input (#483)
Problem: the 'Type your own answer' inline input box did not inherit any input history, so users could not scroll back through prior answers the way `vim.fn.input()` allows with `<C-p>`/`<C-n>`. Solution: bind insert-mode `<C-p>`/`<C-n>` in `inline_input.lua` that walk vim's built-in `input` history via `vim.fn.histget`/`histadd`, sharing the same history list as `vim.fn.input()` itself.
1 parent cdf00c5 commit bafc8e7

2 files changed

Lines changed: 252 additions & 4 deletions

File tree

lua/opencode/ui/inline_input.lua

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,61 @@ function M.open(opts)
9595
opts.on_cancel()
9696
end
9797

98+
-- hist_index 0 = at the in-progress text; N >= 1 = Nth-from-newest in
99+
-- vim's "input" history (shared with vim.fn.input).
100+
local hist_index = 0
101+
local hist_snapshot
102+
103+
local function buf_set(text)
104+
local lines = vim.split(text or '', '\n', { plain = true })
105+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
106+
if vim.api.nvim_win_is_valid(win) then
107+
local last = lines[#lines] or ''
108+
pcall(vim.api.nvim_win_set_cursor, win, { math.max(1, #lines), #last })
109+
end
110+
-- Replacing all lines can drop the editor out of insert mode; re-enter
111+
-- so <C-n>/typing keeps working.
112+
vim.schedule(function()
113+
if not closed and vim.api.nvim_win_is_valid(win) then
114+
pcall(vim.cmd.startinsert)
115+
end
116+
end)
117+
resize_height()
118+
end
119+
120+
local function hist_prev()
121+
local entry = vim.fn.histget('input', -(hist_index + 1))
122+
if entry == '' then
123+
return
124+
end
125+
if hist_index == 0 then
126+
hist_snapshot = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n')
127+
end
128+
hist_index = hist_index + 1
129+
buf_set(entry)
130+
end
131+
132+
local function hist_next()
133+
if hist_index == 0 then
134+
return
135+
end
136+
if hist_index == 1 then
137+
buf_set(hist_snapshot or '')
138+
hist_index = 0
139+
hist_snapshot = nil
140+
return
141+
end
142+
hist_index = hist_index - 1
143+
buf_set(vim.fn.histget('input', -hist_index))
144+
end
145+
98146
vim.fn.prompt_setcallback(buf, function(text)
99147
close()
100-
if text ~= '' then
101-
opts.on_submit(text)
102-
else
103-
opts.on_cancel()
148+
if text == '' then
149+
return opts.on_cancel()
104150
end
151+
vim.fn.histadd('input', text)
152+
opts.on_submit(text)
105153
end)
106154

107155
vim.keymap.set('i', '<C-c>', function()
@@ -112,6 +160,9 @@ function M.open(opts)
112160
cancel_with_draft()
113161
end, { buffer = buf, silent = true, nowait = true })
114162

163+
vim.keymap.set('i', '<C-p>', hist_prev, { buffer = buf, silent = true, nowait = true })
164+
vim.keymap.set('i', '<C-n>', hist_next, { buffer = buf, silent = true, nowait = true })
165+
115166
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
116167
buffer = buf,
117168
callback = function()

tests/unit/inline_input_spec.lua

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local inline_input = require('opencode.ui.inline_input')
2+
local stub = require('luassert.stub')
23

34
describe('inline_input', function()
45
local anchor_buf
@@ -323,4 +324,200 @@ describe('inline_input', function()
323324

324325
assert.equals(text, submitted)
325326
end)
327+
328+
describe('history navigation (<C-p> / <C-n>)', function()
329+
local histget_stub
330+
local histadd_stub
331+
332+
-- Build a vim.fn.histget stub that mirrors vim's "input" history: negative
333+
-- indices walk from most recent backwards, and any further-out entry
334+
-- returns '' (matching the real |histget()|).
335+
local function with_history(history)
336+
histget_stub:revert()
337+
histget_stub = stub(vim.fn, 'histget')
338+
histget_stub.invokes(function(history_name, idx)
339+
assert.equals('input', history_name)
340+
local real = idx
341+
if type(real) == 'number' and real < 0 then
342+
local entry = history[-real]
343+
return entry or ''
344+
end
345+
return ''
346+
end)
347+
348+
histadd_stub:revert()
349+
histadd_stub = stub(vim.fn, 'histadd')
350+
histadd_stub.returns(1)
351+
end
352+
353+
-- Invoke the buffer-local insert-mode keymap callback directly. This
354+
-- avoids headless mode quirks where nvim_buf_set_lines can drop us out
355+
-- of insert mode, and lets us assert the keymap wiring + history state
356+
-- machine independently of the mode state.
357+
local function call_keymap(input, lhs)
358+
local target = lhs:gsub('^<(.-)>$', function(k)
359+
return '<' .. k:upper() .. '>'
360+
end)
361+
for _, m in ipairs(vim.api.nvim_buf_get_keymap(input.buf, 'i')) do
362+
if m.lhs == target and m.callback then
363+
m.callback()
364+
return true
365+
end
366+
end
367+
return false
368+
end
369+
370+
local function buf_lines(input)
371+
return vim.api.nvim_buf_get_lines(input.buf, 0, -1, false)
372+
end
373+
374+
before_each(function()
375+
histget_stub = stub(vim.fn, 'histget')
376+
histadd_stub = stub(vim.fn, 'histadd')
377+
end)
378+
379+
after_each(function()
380+
if histget_stub then
381+
histget_stub:revert()
382+
end
383+
if histadd_stub then
384+
histadd_stub:revert()
385+
end
386+
end)
387+
388+
it('walks forward through history with <C-p>', function()
389+
with_history({ [1] = 'newest', [2] = 'middle', [3] = 'oldest' })
390+
local input = open_input(0, 0)
391+
392+
assert.is_true(call_keymap(input, '<C-p>'))
393+
assert.are.same({ 'newest' }, buf_lines(input))
394+
395+
assert.is_true(call_keymap(input, '<C-p>'))
396+
assert.are.same({ 'middle' }, buf_lines(input))
397+
398+
assert.is_true(call_keymap(input, '<C-p>'))
399+
assert.are.same({ 'oldest' }, buf_lines(input))
400+
end)
401+
402+
it('caps <C-p> at the oldest history entry', function()
403+
with_history({ [1] = 'newest', [2] = 'middle' })
404+
local input = open_input(0, 0)
405+
406+
for _ = 1, 5 do
407+
call_keymap(input, '<C-p>')
408+
end
409+
assert.are.same({ 'middle' }, buf_lines(input))
410+
end)
411+
412+
it('restores the in-progress draft with <C-n>', function()
413+
with_history({ [1] = 'newest', [2] = 'middle' })
414+
local input = open_input(0, 0)
415+
416+
vim.api.nvim_buf_set_lines(input.buf, 0, 1, false, { 'draft' })
417+
call_keymap(input, '<C-p>')
418+
assert.are.same({ 'newest' }, buf_lines(input))
419+
420+
call_keymap(input, '<C-n>')
421+
assert.are.same({ 'draft' }, buf_lines(input))
422+
end)
423+
424+
it('walks back through history with <C-n>', function()
425+
with_history({ [1] = 'newest', [2] = 'middle', [3] = 'oldest' })
426+
local input = open_input(0, 0)
427+
428+
call_keymap(input, '<C-p>')
429+
call_keymap(input, '<C-p>')
430+
call_keymap(input, '<C-p>')
431+
assert.are.same({ 'oldest' }, buf_lines(input))
432+
433+
call_keymap(input, '<C-n>')
434+
assert.are.same({ 'middle' }, buf_lines(input))
435+
436+
call_keymap(input, '<C-n>')
437+
assert.are.same({ 'newest' }, buf_lines(input))
438+
end)
439+
440+
it('is a no-op when <C-p> is pressed with empty history', function()
441+
with_history({})
442+
local input = open_input(0, 0)
443+
444+
vim.api.nvim_buf_set_lines(input.buf, 0, 1, false, { 'draft' })
445+
call_keymap(input, '<C-p>')
446+
assert.are.same({ 'draft' }, buf_lines(input))
447+
end)
448+
449+
it('is a no-op when <C-n> is pressed without first entering history', function()
450+
with_history({ [1] = 'newest' })
451+
local input = open_input(0, 0)
452+
453+
vim.api.nvim_buf_set_lines(input.buf, 0, 1, false, { 'draft' })
454+
call_keymap(input, '<C-n>')
455+
assert.are.same({ 'draft' }, buf_lines(input))
456+
end)
457+
458+
it('handles multi-line history entries', function()
459+
with_history({ [1] = 'line1\nline2' })
460+
local input = open_input(0, 0)
461+
462+
call_keymap(input, '<C-p>')
463+
assert.are.same({ 'line1', 'line2' }, buf_lines(input))
464+
end)
465+
466+
it('places the cursor at the end of the inserted history entry', function()
467+
with_history({ [1] = 'first', [2] = 'second' })
468+
local input = open_input(0, 0)
469+
470+
call_keymap(input, '<C-p>')
471+
local cursor1 = vim.api.nvim_win_get_cursor(input.win)
472+
-- Cursor lands on the last char (normal-mode clamp) or the append
473+
-- position (insert-mode). Production runs in insert mode, so this
474+
-- matches user-visible behavior either way.
475+
assert.are.equal(1, cursor1[1])
476+
assert.is_true(cursor1[2] == #('first') or cursor1[2] == #('first') - 1)
477+
478+
call_keymap(input, '<C-p>')
479+
local cursor2 = vim.api.nvim_win_get_cursor(input.win)
480+
assert.are.equal(1, cursor2[1])
481+
assert.is_true(cursor2[2] == #('second') or cursor2[2] == #('second') - 1)
482+
end)
483+
484+
it('after restoring the draft, <C-p> re-enters history from the snapshot', function()
485+
with_history({ [1] = 'alpha', [2] = 'beta' })
486+
local input = open_input(0, 0)
487+
488+
vim.api.nvim_buf_set_lines(input.buf, 0, 1, false, { 'typed' })
489+
call_keymap(input, '<C-p>')
490+
call_keymap(input, '<C-n>')
491+
assert.are.same({ 'typed' }, buf_lines(input))
492+
493+
call_keymap(input, '<C-p>')
494+
assert.are.same({ 'alpha' }, buf_lines(input))
495+
end)
496+
497+
it('writes submitted text to vim\'s "input" history (shared with vim.fn.input)', function()
498+
with_history({})
499+
500+
local submitted
501+
open_input(0, 0, function(value)
502+
submitted = value
503+
end)
504+
vim.api.nvim_feedkeys(vim.keycode('ianswer<CR>'), 'x', false)
505+
506+
assert.equals('answer', submitted)
507+
assert.stub(histadd_stub).was_called_with('input', 'answer')
508+
end)
509+
510+
it('does not write to history when submission is empty', function()
511+
with_history({})
512+
513+
local submitted
514+
open_input(0, 0, function(value)
515+
submitted = value
516+
end)
517+
vim.api.nvim_feedkeys(vim.keycode('<CR>'), 'x', false)
518+
519+
assert.is_nil(submitted)
520+
assert.stub(histadd_stub).was_not_called()
521+
end)
522+
end)
326523
end)

0 commit comments

Comments
 (0)