Skip to content

Commit fb34b46

Browse files
authored
feat(ui): add multi-question navigation and tabbed question UI (#383)
* 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. * feat(ui/renderer): question navigation and flush
1 parent ede1597 commit fb34b46

16 files changed

Lines changed: 2885 additions & 1085 deletions

lua/opencode/ui/dialog.lua

Lines changed: 92 additions & 9 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,30 @@ 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('Move: `j/k` or `↑/↓`')
236+
end
237+
238+
if keymaps.left and #keymaps.left > 0 and keymaps.right and #keymaps.right > 0 then
239+
local line = output:add_line('Question: `h/l` or `<-/->`')
189240
end
190241

191242
if keymaps.select and keymaps.select ~= '' then
192-
local select_text = string.format('Select: `%s`', keymaps.select)
243+
local select_text = 'Select: `<CR>`'
193244
if keymaps.number_shortcuts and option_count > 0 then
194245
local max_shortcut = math.min(option_count, 9)
195246
select_text = select_text .. string.format(' or `1-%d`', max_shortcut)
196247
end
197-
table.insert(legend_parts, select_text)
248+
local line = output:add_line(select_text)
198249
end
199250

200251
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, ' '))
252+
local line = output:add_line('Close: `<Esc>`')
206253
end
207254
else
208255
local message = options.unfocused_message or 'Focus Opencode window to interact'
@@ -353,6 +400,42 @@ function Dialog:_setup_keymaps()
353400
end
354401
end
355402

403+
if keymaps.left then
404+
for _, key in ipairs(keymaps.left) do
405+
if key and key ~= '' then
406+
vim.keymap.set(
407+
'n',
408+
key,
409+
function()
410+
self:navigate_group(-1)
411+
end,
412+
vim.tbl_extend('force', keymap_opts, {
413+
desc = 'Dialog: navigate left',
414+
})
415+
)
416+
table.insert(self._keymaps, key)
417+
end
418+
end
419+
end
420+
421+
if keymaps.right then
422+
for _, key in ipairs(keymaps.right) do
423+
if key and key ~= '' then
424+
vim.keymap.set(
425+
'n',
426+
key,
427+
function()
428+
self:navigate_group(1)
429+
end,
430+
vim.tbl_extend('force', keymap_opts, {
431+
desc = 'Dialog: navigate right',
432+
})
433+
)
434+
table.insert(self._keymaps, key)
435+
end
436+
end
437+
end
438+
356439
if keymaps.select and keymaps.select ~= '' then
357440
vim.keymap.set(
358441
'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

0 commit comments

Comments
 (0)