Skip to content

Commit b7c4e96

Browse files
committed
feat(ui): add multi-question navigation and tabbed question UI
Add group navigation to Dialog (navigate_group, set/get_group_selection, on_navigate_group) and left/right keymaps. Update legend formatting to show key hints. Update highlight definitions for question tabs and key hints. Update question_window to track answers by question index, render tabbed question headers (showing active/done/pending state), wait until all questions are answered before sending the reply, and synchronize dialog group selection with the current question. Add unit tests covering multi-question flow, tab rendering, and navigation.
1 parent 36d313d commit b7c4e96

4 files changed

Lines changed: 344 additions & 14 deletions

File tree

lua/opencode/ui/dialog.lua

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
---@field on_select function(index: integer) Called when an option is selected
44
---@field on_dismiss? function() Called when dialog is dismissed
55
---@field on_navigate? function() Called when selection changes
6+
---@field on_navigate_group? function(index: integer) Called when group selection changes
67
---@field get_option_count function(): integer Returns the total number of options
8+
---@field get_group_count? function(): integer Returns the total number of groups
79
---@field check_focused? function(): boolean Returns whether dialog should be active
810
---@field keymaps? DialogKeymaps Custom keymap configuration
911
---@field namespace_prefix? string Prefix for vim.on_key namespace (default: 'opencode_dialog')
@@ -12,6 +14,8 @@
1214
---@class DialogKeymaps
1315
---@field up? string[] Keys for navigating up (default: {'k', '<Up>'})
1416
---@field down? string[] Keys for navigating down (default: {'j', '<Down>'})
17+
---@field left? string[] Keys for navigating left between groups
18+
---@field right? string[] Keys for navigating right between groups
1519
---@field select? string Key for selecting current option (default: '<CR>')
1620
---@field dismiss? string Key for dismissing dialog (default: '<Esc>')
1721
---@field number_shortcuts? boolean Enable 1-9 number shortcuts (default: true)
@@ -22,6 +26,7 @@
2226
---@field private _key_capture_ns integer? Namespace for vim.on_key
2327
---@field private _selected_index integer Currently selected option index
2428
---@field private _active boolean Whether dialog is currently active
29+
---@field private _group_index integer Currently selected group index
2530
local Dialog = {}
2631
Dialog.__index = Dialog
2732

@@ -35,6 +40,8 @@ function Dialog.new(config)
3540
local default_keymaps = {
3641
up = { 'k', '<Up>' },
3742
down = { 'j', '<Down>' },
43+
left = {},
44+
right = {},
3845
select = '<CR>',
3946
dismiss = '<Esc>',
4047
number_shortcuts = true,
@@ -52,6 +59,7 @@ function Dialog.new(config)
5259
self._keymaps = {}
5360
self._key_capture_ns = nil
5461
self._selected_index = 1
62+
self._group_index = 1
5563
self._active = false
5664

5765
return self
@@ -72,6 +80,24 @@ function Dialog:set_selection(index)
7280
end
7381
end
7482

83+
---@return integer
84+
function Dialog:get_group_selection()
85+
return self._group_index
86+
end
87+
88+
---@param index integer
89+
function Dialog:set_group_selection(index)
90+
local group_count = self._config.get_group_count and self._config.get_group_count() or 0
91+
if group_count == 0 then
92+
self._group_index = 1
93+
return
94+
end
95+
96+
if index >= 1 and index <= group_count then
97+
self._group_index = index
98+
end
99+
end
100+
75101
---Navigate selection by delta (positive for down, negative for up)
76102
---@param delta integer Amount to move selection
77103
function Dialog:navigate(delta)
@@ -98,6 +124,28 @@ function Dialog:navigate(delta)
98124
end
99125
end
100126

127+
---@param delta integer
128+
function Dialog:navigate_group(delta)
129+
if not self._active or not self._config.check_focused() or not self._config.on_navigate_group then
130+
return
131+
end
132+
133+
local group_count = self._config.get_group_count and self._config.get_group_count() or 0
134+
if group_count <= 1 then
135+
return
136+
end
137+
138+
self._group_index = self._group_index + delta
139+
140+
if self._group_index < 1 then
141+
self._group_index = group_count
142+
elseif self._group_index > group_count then
143+
self._group_index = 1
144+
end
145+
146+
self._config.on_navigate_group(self._group_index)
147+
end
148+
101149
---Select the current option
102150
function Dialog:select()
103151
if not self._active or not self._config.check_focused() then
@@ -178,31 +226,42 @@ function Dialog:format_legend(output, options)
178226
end
179227

180228
if ui.is_opencode_focused() then
181-
local legend_parts = {}
182229
local keymaps = self._config.keymaps
183230
if not keymaps then
184231
return
185232
end
186233

187234
if keymaps.up and #keymaps.up > 0 and keymaps.down and #keymaps.down > 0 then
188-
table.insert(legend_parts, string.format('Navigate: `%s`/`%s` or `↑`/`↓`', keymaps.down[1], keymaps.up[1]))
235+
local line = output:add_line(string.format('Move: j/k or %s/%s', '', ''))
236+
output:add_extmark(line - 1, { start_col = 6, end_col = 9, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
237+
output:add_extmark(line - 1, { start_col = 13, end_col = 16, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
238+
end
239+
240+
if keymaps.left and #keymaps.left > 0 and keymaps.right and #keymaps.right > 0 then
241+
local line = output:add_line('Question: h/l or <-/->')
242+
output:add_extmark(line - 1, { start_col = 10, end_col = 13, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
243+
output:add_extmark(line - 1, { start_col = 17, end_col = 23, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
189244
end
190245

191246
if keymaps.select and keymaps.select ~= '' then
192-
local select_text = string.format('Select: `%s`', keymaps.select)
247+
local select_text = 'Select: <CR>'
248+
if keymaps.number_shortcuts and option_count > 0 then
249+
local max_shortcut = math.min(option_count, 9)
250+
select_text = select_text .. string.format(' or 1-%d', max_shortcut)
251+
end
252+
local line = output:add_line(select_text)
253+
output:add_extmark(line - 1, { start_col = 8, end_col = 12, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
193254
if keymaps.number_shortcuts and option_count > 0 then
194255
local max_shortcut = math.min(option_count, 9)
195-
select_text = select_text .. string.format(' or `1-%d`', max_shortcut)
256+
local suffix = string.format('1-%d', max_shortcut)
257+
local start_col = #select_text - #suffix
258+
output:add_extmark(line - 1, { start_col = start_col, end_col = #select_text, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
196259
end
197-
table.insert(legend_parts, select_text)
198260
end
199261

200262
if keymaps.dismiss and keymaps.dismiss ~= '' then
201-
table.insert(legend_parts, string.format('Dismiss: `%s`', keymaps.dismiss))
202-
end
203-
204-
if #legend_parts > 0 then
205-
output:add_line(table.concat(legend_parts, ' '))
263+
local line = output:add_line('Close: <Esc>')
264+
output:add_extmark(line - 1, { start_col = 7, end_col = 12, hl_group = 'OpencodeQuestionKeyHint' } --[[@as OutputExtmark]])
206265
end
207266
else
208267
local message = options.unfocused_message or 'Focus Opencode window to interact'
@@ -353,6 +412,42 @@ function Dialog:_setup_keymaps()
353412
end
354413
end
355414

415+
if keymaps.left then
416+
for _, key in ipairs(keymaps.left) do
417+
if key and key ~= '' then
418+
vim.keymap.set(
419+
'n',
420+
key,
421+
function()
422+
self:navigate_group(-1)
423+
end,
424+
vim.tbl_extend('force', keymap_opts, {
425+
desc = 'Dialog: navigate left',
426+
})
427+
)
428+
table.insert(self._keymaps, key)
429+
end
430+
end
431+
end
432+
433+
if keymaps.right then
434+
for _, key in ipairs(keymaps.right) do
435+
if key and key ~= '' then
436+
vim.keymap.set(
437+
'n',
438+
key,
439+
function()
440+
self:navigate_group(1)
441+
end,
442+
vim.tbl_extend('force', keymap_opts, {
443+
desc = 'Dialog: navigate right',
444+
})
445+
)
446+
table.insert(self._keymaps, key)
447+
end
448+
end
449+
end
450+
356451
if keymaps.select and keymaps.select ~= '' then
357452
vim.keymap.set(
358453
'n',

lua/opencode/ui/highlight.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ function M.setup()
4848
vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true })
4949
vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#E3F2FD', default = true })
5050
vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true })
51+
vim.api.nvim_set_hl(0, 'OpencodeQuestionTabActive', { bg = '#E3F2FD', fg = '#1976D2', bold = true, default = true })
52+
vim.api.nvim_set_hl(0, 'OpencodeQuestionTabDone', { fg = '#2E7D32', bold = true, default = true })
53+
vim.api.nvim_set_hl(0, 'OpencodeQuestionTabPending', { fg = '#757575', default = true })
54+
vim.api.nvim_set_hl(0, 'OpencodeQuestionKeyHint', { fg = '#1976D2', bold = true, default = true })
5155
vim.api.nvim_set_hl(0, 'OpencodeChangedLines', { bg = '#FFF3BF', default = true })
5256
else
5357
vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true })
@@ -92,6 +96,10 @@ function M.setup()
9296
vim.api.nvim_set_hl(0, 'OpencodeQuestionOption', { link = 'Normal', default = true })
9397
vim.api.nvim_set_hl(0, 'OpencodeQuestionBorder', { fg = '#2B3A5A', default = true })
9498
vim.api.nvim_set_hl(0, 'OpencodeQuestionTitle', { link = '@label', bold = true, default = true })
99+
vim.api.nvim_set_hl(0, 'OpencodeQuestionTabActive', { bg = '#2B3A5A', fg = '#61AFEF', bold = true, default = true })
100+
vim.api.nvim_set_hl(0, 'OpencodeQuestionTabDone', { fg = '#98C379', bold = true, default = true })
101+
vim.api.nvim_set_hl(0, 'OpencodeQuestionTabPending', { fg = '#7F8490', default = true })
102+
vim.api.nvim_set_hl(0, 'OpencodeQuestionKeyHint', { fg = '#61AFEF', bold = true, default = true })
95103
vim.api.nvim_set_hl(0, 'OpencodeChangedLines', { bg = '#3D3520', default = true })
96104
end
97105
end

lua/opencode/ui/question_window.lua

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,48 @@ M._collected_answers = {}
1313
M._answering = false
1414
M._dialog = nil
1515

16+
---@param index integer
17+
---@return string[]|nil
18+
local function get_answer_for_index(index)
19+
local answer = M._collected_answers[index]
20+
if type(answer) ~= 'table' or #answer == 0 then
21+
return nil
22+
end
23+
return answer
24+
end
25+
26+
---@return boolean
27+
local function has_all_answers()
28+
local request = M._current_question
29+
local questions = request and request.questions or {}
30+
if #questions == 0 then
31+
return false
32+
end
33+
34+
for i = 1, #questions do
35+
if not get_answer_for_index(i) then
36+
return false
37+
end
38+
end
39+
40+
return true
41+
end
42+
43+
---@return integer|nil
44+
local function get_next_unanswered_question_index()
45+
local request = M._current_question
46+
local questions = request and request.questions or {}
47+
if #questions == 0 then
48+
return nil
49+
end
50+
51+
for i = 1, #questions do
52+
if not get_answer_for_index(i) then
53+
return i
54+
end
55+
end
56+
end
57+
1658
---@param question_request OpencodeQuestionRequest|nil
1759
---@return boolean
1860
function M.matches_active_question(question_request)
@@ -159,8 +201,8 @@ function M.show_question(question_request)
159201
end
160202

161203
M._current_question = question_request
162-
M._current_question_index = 1
163204
M._collected_answers = {}
205+
M._current_question_index = 1
164206

165207
if config.ui.questions and config.ui.questions.use_vim_ui_select then
166208
M._show_question_with_vim_ui_select()
@@ -245,13 +287,13 @@ local function answer_current_question(answer_value)
245287
return
246288
end
247289

248-
table.insert(M._collected_answers, type(answer_value) == 'table' and answer_value or { answer_value })
249-
M._current_question_index = M._current_question_index + 1
290+
M._collected_answers[M._current_question_index] = type(answer_value) == 'table' and answer_value or { answer_value }
250291

251-
if M._current_question_index > #request.questions then
292+
if has_all_answers() then
252293
M._send_reply(request.id, M._collected_answers)
253294
M.clear_question()
254295
else
296+
M._current_question_index = get_next_unanswered_question_index() or M._current_question_index
255297
M._answering = false
256298
M._clear_dialog()
257299
M._setup_dialog()
@@ -329,6 +371,49 @@ local function add_other_if_missing(options)
329371
return result
330372
end
331373

374+
---@param output Output
375+
local function format_question_tabs(output)
376+
local request = M._current_question
377+
if not request or #request.questions <= 1 then
378+
return
379+
end
380+
381+
local line = ''
382+
local segments = {}
383+
384+
for i, question in ipairs(request.questions) do
385+
local label = question.header ~= '' and question.header or ('Q' .. i)
386+
local is_active = i == M._current_question_index
387+
local is_done = get_answer_for_index(i) ~= nil
388+
local marker = is_done and 'x' or ' '
389+
local segment = string.format(' %d [%s] %s ', i, marker, label)
390+
391+
if #line > 0 then
392+
line = line .. ' '
393+
end
394+
395+
local start_col = #line
396+
line = line .. segment
397+
table.insert(segments, {
398+
start_col = start_col,
399+
end_col = #line,
400+
hl_group = is_active and 'OpencodeQuestionTabActive'
401+
or (is_done and 'OpencodeQuestionTabDone' or 'OpencodeQuestionTabPending'),
402+
})
403+
end
404+
405+
local line_idx = output:add_line(line)
406+
for _, segment in ipairs(segments) do
407+
output:add_extmark(line_idx - 1, {
408+
start_col = segment.start_col,
409+
end_col = segment.end_col,
410+
hl_group = segment.hl_group,
411+
} --[[@as OutputExtmark]])
412+
end
413+
414+
output:add_line('')
415+
end
416+
332417
---@param output Output
333418
function M.format_display(output)
334419
if not M.has_question() or M._answering then
@@ -347,6 +432,8 @@ function M.format_display(output)
347432
progress = string.format(' (%d/%d)', M._current_question_index, #M._current_question.questions)
348433
end
349434

435+
format_question_tabs(output)
436+
350437
-- Prepare options
351438
local options_to_display = add_other_if_missing(question_info.options)
352439
local options = {}
@@ -417,6 +504,12 @@ function M._setup_dialog()
417504
render_question()
418505
end
419506

507+
---@param index integer
508+
local function on_navigate_group(index)
509+
M._current_question_index = index
510+
render_question()
511+
end
512+
420513
---@return integer
421514
local function get_option_count()
422515
local question_info = M.get_current_question_info()
@@ -428,11 +521,20 @@ function M._setup_dialog()
428521
on_select = on_select,
429522
on_dismiss = on_dismiss,
430523
on_navigate = on_navigate,
524+
on_navigate_group = on_navigate_group,
431525
get_option_count = get_option_count,
526+
get_group_count = function()
527+
return M._current_question and #M._current_question.questions or 0
528+
end,
432529
check_focused = check_focused,
433530
namespace_prefix = 'opencode_question',
531+
keymaps = {
532+
left = { 'h', '<Left>' },
533+
right = { 'l', '<Right>' },
534+
},
434535
})
435536

537+
M._dialog:set_group_selection(M._current_question_index)
436538
M._dialog:setup()
437539
end
438540

0 commit comments

Comments
 (0)