From 84bc37355fdc88a0dfa933b73ae909e2bc2fcc80 Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Sun, 11 Jan 2026 11:46:03 -0800 Subject: [PATCH 1/8] feat(ui): add question tool support - Add API client methods for question endpoints (list, reply, reject) - Add question event types (question.asked, question.replied, question.rejected) - Add question picker UI that shows full question as notification and uses short header as prompt to avoid truncation - Support single-select and multi-select questions with 'Other' option - Add question icon to icon presets - Wire up question.asked event handler in renderer This enables opencode.nvim to handle the mcp_question tool that allows the AI to ask users questions during execution. --- lua/opencode/api_client.lua | 26 ++++ lua/opencode/event_manager.lua | 15 ++ lua/opencode/types.lua | 18 +++ lua/opencode/ui/icons.lua | 2 + lua/opencode/ui/question_picker.lua | 230 ++++++++++++++++++++++++++++ lua/opencode/ui/renderer.lua | 20 +++ 6 files changed, 311 insertions(+) create mode 100644 lua/opencode/ui/question_picker.lua diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 4bc05083..4f384ee0 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -391,6 +391,32 @@ function OpencodeApiClient:list_agents(directory) return self:_call('/agent', 'GET', nil, { directory = directory }) end +-- Question endpoints + +--- List pending questions +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:list_questions(directory) + return self:_call('/question', 'GET', nil, { directory = directory }) +end + +--- Reply to a question +--- @param requestID string Question request ID (required) +--- @param answers string[][] Array of answers (each answer is array of selected labels) +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:reply_question(requestID, answers, directory) + return self:_call('/question/' .. requestID .. '/reply', 'POST', { answers = answers }, { directory = directory }) +end + +--- Reject a question +--- @param requestID string Question request ID (required) +--- @param directory string|nil Directory path +--- @return Promise +function OpencodeApiClient:reject_question(requestID, directory) + return self:_call('/question/' .. requestID .. '/reject', 'POST', nil, { directory = directory }) +end + --- Subscribe to events (streaming) --- @param directory string|nil Directory path --- @param on_event fun(event: table) Event callback diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index c9b1afbc..18a772d2 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -95,6 +95,18 @@ local util = require('opencode.util') --- @class RestorePointCreatedEvent --- @field restore_point RestorePoint +--- @class EventQuestionAsked +--- @field type "question.asked" +--- @field properties OpencodeQuestionRequest + +--- @class EventQuestionReplied +--- @field type "question.replied" +--- @field properties { sessionID: string, requestID: string, answers: string[][] } + +--- @class EventQuestionRejected +--- @field type "question.rejected" +--- @field properties { sessionID: string, requestID: string } + --- @alias OpencodeEventName --- | "installation.updated" --- | "lsp.client.diagnostics" @@ -109,6 +121,9 @@ local util = require('opencode.util') --- | "session.error" --- | "permission.updated" --- | "permission.replied" +--- | "question.asked" +--- | "question.replied" +--- | "question.rejected" --- | "file.edited" --- | "file.watcher.updated" --- | "server.connected" diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 310770dd..abbffd23 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -280,6 +280,24 @@ ---@field prompt string The subtask prompt ---@field description string Description of the subtask +-- Question types + +---@class OpencodeQuestionOption +---@field label string Display text +---@field description string Explanation of choice + +---@class OpencodeQuestionInfo +---@field question string Complete question +---@field header string Very short label (max 12 chars) +---@field options OpencodeQuestionOption[] Available choices +---@field multiple? boolean Allow selecting multiple choices + +---@class OpencodeQuestionRequest +---@field id string Question request ID +---@field sessionID string Session ID +---@field questions OpencodeQuestionInfo[] Questions to ask +---@field tool? { messageID: string, callID: string } + ---@class MessageTokenCount ---@field reasoning number ---@field input number diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index 53baf91f..93a4f1f2 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -27,6 +27,7 @@ local presets = { agent = '󰚩 ', reference = ' ', reasoning = '󰧑 ', + question = ' ', -- statuses status_on = ' ', status_off = ' ', @@ -64,6 +65,7 @@ local presets = { attached_file = '@', agent = '@', reference = '@', + question = '?', -- statuses status_on = 'ON', status_off = 'OFF', diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua new file mode 100644 index 00000000..0fc4d2b9 --- /dev/null +++ b/lua/opencode/ui/question_picker.lua @@ -0,0 +1,230 @@ +-- Question picker UI for handling question tool requests +local state = require('opencode.state') +local icons = require('opencode.ui.icons') + +local M = {} + +-- Track current question being displayed +M.current_question = nil + +--- Show a question picker for the user to answer +--- @param question OpencodeQuestionRequest +function M.show(question) + if not question or not question.questions or #question.questions == 0 then + return + end + + M.current_question = question + + -- Process questions sequentially + M._show_question(question, 1, {}) +end + +--- Show a single question from the request +--- @param request OpencodeQuestionRequest +--- @param index number Current question index (1-based) +--- @param collected_answers string[][] Answers collected so far +function M._show_question(request, index, collected_answers) + local questions = request.questions + if index > #questions then + -- All questions answered, send reply + M._send_reply(request.id, collected_answers) + return + end + + local q = questions[index] + local items = {} + + for _, opt in ipairs(q.options or {}) do + table.insert(items, { + label = opt.label, + description = opt.description or '', + }) + end + + -- Add "Other" option for custom input + table.insert(items, { + label = 'Other', + description = 'Provide custom response', + is_other = true, + }) + + -- Show the full question as a notification so user can see it without truncation + local question_icon = icons.get('question') or '?' + local progress = #questions > 1 and string.format(' (%d/%d)', index, #questions) or '' + vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) + + -- Use the short header as the prompt (won't get cut off) + local prompt = q.header .. progress .. ': ' + + if q.multiple then + M._show_multiselect(request, index, collected_answers, q, items, prompt) + else + M._show_single_select(request, index, collected_answers, q, items, prompt) + end +end + +--- Show single-select picker +--- @param request OpencodeQuestionRequest +--- @param index number +--- @param collected_answers string[][] +--- @param q OpencodeQuestionInfo +--- @param items table[] +--- @param prompt string +function M._show_single_select(request, index, collected_answers, q, items, prompt) + vim.ui.select(items, { + prompt = prompt, + format_item = function(item) + if item.description and item.description ~= '' then + return item.label .. ' - ' .. item.description + end + return item.label + end, + }, function(choice) + if not choice then + -- User cancelled - reject the question + M._send_reject(request.id) + return + end + + if choice.is_other then + -- Get custom input + vim.ui.input({ prompt = 'Enter your response: ' }, function(input) + if input and input ~= '' then + table.insert(collected_answers, { input }) + M._show_question(request, index + 1, collected_answers) + else + M._send_reject(request.id) + end + end) + else + -- Reply with selected option + table.insert(collected_answers, { choice.label }) + M._show_question(request, index + 1, collected_answers) + end + end) +end + +--- Show multi-select picker using checkboxes +--- @param request OpencodeQuestionRequest +--- @param index number +--- @param collected_answers string[][] +--- @param q OpencodeQuestionInfo +--- @param items table[] +--- @param prompt string +function M._show_multiselect(request, index, collected_answers, q, items, prompt) + -- For multiselect, we use a simple approach: show items with instructions + -- User can select multiple by choosing "Done" when finished + local selected = {} + + local function show_picker() + local display_items = {} + + for _, item in ipairs(items) do + if not item.is_other then + local prefix = selected[item.label] and '[x] ' or '[ ] ' + table.insert(display_items, { + label = item.label, + description = item.description, + display = prefix .. item.label, + is_other = false, + }) + end + end + + -- Add done and other options + table.insert(display_items, { + label = '-- Done --', + description = 'Confirm selection', + display = '-- Done --', + is_done = true, + }) + table.insert(display_items, { + label = 'Other', + description = 'Provide custom response', + display = 'Other', + is_other = true, + }) + + vim.ui.select(display_items, { + prompt = prompt .. '(multi): ', + format_item = function(item) + if item.description and item.description ~= '' and not item.is_done then + return item.display .. ' - ' .. item.description + end + return item.display + end, + }, function(choice) + if not choice then + M._send_reject(request.id) + return + end + + if choice.is_done then + -- Collect selected items + local answers = {} + for label, _ in pairs(selected) do + table.insert(answers, label) + end + if #answers == 0 then + vim.notify('Please select at least one option', vim.log.levels.WARN) + show_picker() + return + end + table.insert(collected_answers, answers) + M._show_question(request, index + 1, collected_answers) + elseif choice.is_other then + vim.ui.input({ prompt = 'Enter your response: ' }, function(input) + if input and input ~= '' then + table.insert(collected_answers, { input }) + M._show_question(request, index + 1, collected_answers) + else + show_picker() + end + end) + else + -- Toggle selection + if selected[choice.label] then + selected[choice.label] = nil + else + selected[choice.label] = true + end + show_picker() + end + end) + end + + show_picker() +end + +--- Send reply to the question +--- @param request_id string +--- @param answers string[][] +function M._send_reply(request_id, answers) + M.current_question = nil + if state.api_client then + state.api_client:reply_question(request_id, answers):catch(function(err) + vim.notify('Failed to reply to question: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end +end + +--- Send reject to the question +--- @param request_id string +function M._send_reject(request_id) + M.current_question = nil + if state.api_client then + state.api_client:reject_question(request_id):catch(function(err) + vim.notify('Failed to reject question: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end +end + +--- Check if there's a pending question for the given session +--- @param session_id string +--- @return boolean +function M.has_pending_question(session_id) + return M.current_question ~= nil and M.current_question.sessionID == session_id +end + +return M diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index cf718b02..67e24a6f 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -82,6 +82,7 @@ function M.setup_subscriptions(subscribe) { 'permission.updated', M.on_permission_updated }, { 'permission.asked', M.on_permission_updated }, { 'permission.replied', M.on_permission_replied }, + { 'question.asked', M.on_question_asked }, { 'file.edited', M.on_file_edited }, { 'custom.restore_point.created', M.on_restore_points }, { 'custom.emit_events.finished', M.on_emit_events_finished }, @@ -831,6 +832,25 @@ function M.on_permission_replied(properties) end end +---Event handler for question.asked events +---Shows the question picker UI for the user to answer +---@param properties OpencodeQuestionRequest Event properties +function M.on_question_asked(properties) + if not properties or not properties.id or not properties.questions then + return + end + + -- Only show for current session + if not state.active_session or properties.sessionID ~= state.active_session.id then + return + end + + vim.schedule(function() + local question_picker = require('opencode.ui.question_picker') + question_picker.show(properties) + end) +end + function M.on_file_edited(properties) vim.cmd('checktime') if config.hooks and config.hooks.on_file_edited then From d950b12b880213c0185b3878502848de8f07fcfd Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Sun, 11 Jan 2026 11:50:19 -0800 Subject: [PATCH 2/8] fix(ui): update question icon in icons.lua --- lua/opencode/ui/icons.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index 93a4f1f2..d8d85318 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -27,7 +27,7 @@ local presets = { agent = '󰚩 ', reference = ' ', reasoning = '󰧑 ', - question = ' ', + question = '', -- statuses status_on = ' ', status_off = ' ', From 73a52f69c8de466b44400cadba304a2b6c7002ee Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Sun, 11 Jan 2026 17:08:11 -0800 Subject: [PATCH 3/8] feat(ui): enhance question picker with multi-selection support - add support for multi-selection with confirmation action - introduce dynamic input handling for "Other" responses - refactor user feedback notifications for better context - utilize base_picker for streamlined option formatting and selection Closes #123 --- lua/opencode/ui/base_picker.lua | 22 +-- lua/opencode/ui/question_picker.lua | 211 ++++++++++++++-------------- 2 files changed, 120 insertions(+), 113 deletions(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index cc6fb05d..06add019 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -456,15 +456,21 @@ local function snacks_picker_ui(opts) snack_opts.actions[action_name] = function(_picker, item) if item then - vim.schedule(function() - local items_to_process - if action.multi_selection then - local selected_items = _picker:selected({ fallback = true }) - items_to_process = #selected_items > 1 and selected_items or item - else - items_to_process = item - end + -- Collect selected items before closing (for multi-selection) + local items_to_process + if action.multi_selection then + local selected_items = _picker:selected({ fallback = true }) + items_to_process = #selected_items > 1 and selected_items or item + else + items_to_process = item + end + -- Close picker unless this is a reload action + if not action.reload then + _picker:close() + end + + vim.schedule(function() local new_items = action.fn(items_to_process, opts) Promise.wrap(new_items):and_then(function(resolved_items) if action.reload and resolved_items then diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua index 0fc4d2b9..685f9240 100644 --- a/lua/opencode/ui/question_picker.lua +++ b/lua/opencode/ui/question_picker.lua @@ -1,12 +1,26 @@ -- Question picker UI for handling question tool requests local state = require('opencode.state') local icons = require('opencode.ui.icons') +local base_picker = require('opencode.ui.base_picker') +local config = require('opencode.config') local M = {} -- Track current question being displayed M.current_question = nil +--- Format a question option for the picker +---@param item table Question option item +---@param width? number Optional width +---@return PickerItem +local function format_option(item, width) + local text = item.label + if item.description and item.description ~= '' then + text = text .. ' - ' .. item.description + end + return base_picker.create_picker_item(text, nil, nil, width) +end + --- Show a question picker for the user to answer --- @param question OpencodeQuestionRequest function M.show(question) @@ -49,29 +63,111 @@ function M._show_question(request, index, collected_answers) is_other = true, }) - -- Show the full question as a notification so user can see it without truncation + -- Build title with question local question_icon = icons.get('question') or '?' local progress = #questions > 1 and string.format(' (%d/%d)', index, #questions) or '' - vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) + local title = question_icon .. ' ' .. q.header .. progress - -- Use the short header as the prompt (won't get cut off) - local prompt = q.header .. progress .. ': ' + -- Define actions + local actions = {} if q.multiple then - M._show_multiselect(request, index, collected_answers, q, items, prompt) - else - M._show_single_select(request, index, collected_answers, q, items, prompt) + -- For multi-select, add a confirm action that collects all selections + actions.confirm_multi = { + key = { '', mode = { 'i', 'n' } }, + label = 'confirm', + multi_selection = true, + fn = function(selected, opts) + -- Handle the selection + local selections = type(selected) == 'table' and selected.label == nil and selected or { selected } + + -- Check for "Other" option + local has_other = false + local answers = {} + for _, item in ipairs(selections) do + if item.is_other then + has_other = true + else + table.insert(answers, item.label) + end + end + + if has_other and #answers == 0 then + -- Only "Other" selected, prompt for input + vim.ui.input({ prompt = 'Enter your response: ' }, function(input) + if input and input ~= '' then + table.insert(collected_answers, { input }) + M._show_question(request, index + 1, collected_answers) + else + M._send_reject(request.id) + end + end) + elseif #answers > 0 then + table.insert(collected_answers, answers) + M._show_question(request, index + 1, collected_answers) + else + vim.notify('Please select at least one option', vim.log.levels.WARN) + end + + return nil -- Don't reload + end, + } + end + + -- Show full question as notification for context + vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) + + -- Use base_picker + local success = base_picker.pick({ + items = items, + format_fn = format_option, + title = title, + actions = actions, + width = config.ui.picker_width or 80, + callback = function(selected) + if not selected then + -- User cancelled + M._send_reject(request.id) + return + end + + -- For single-select (no multi action defined), handle here + if not q.multiple then + if selected.is_other then + vim.ui.input({ prompt = 'Enter your response: ' }, function(input) + if input and input ~= '' then + table.insert(collected_answers, { input }) + M._show_question(request, index + 1, collected_answers) + else + M._send_reject(request.id) + end + end) + else + table.insert(collected_answers, { selected.label }) + M._show_question(request, index + 1, collected_answers) + end + end + -- Multi-select is handled by the confirm_multi action + end, + }) + + -- Fallback to vim.ui.select if no picker available + if not success then + M._fallback_picker(request, index, collected_answers, q, items) end end ---- Show single-select picker +--- Fallback to vim.ui.select when no picker is available --- @param request OpencodeQuestionRequest --- @param index number --- @param collected_answers string[][] --- @param q OpencodeQuestionInfo --- @param items table[] ---- @param prompt string -function M._show_single_select(request, index, collected_answers, q, items, prompt) +function M._fallback_picker(request, index, collected_answers, q, items) + local question_icon = icons.get('question') or '?' + local progress = #request.questions > 1 and string.format(' (%d/%d)', index, #request.questions) or '' + local prompt = q.header .. progress .. ': ' + vim.ui.select(items, { prompt = prompt, format_item = function(item) @@ -82,13 +178,11 @@ function M._show_single_select(request, index, collected_answers, q, items, prom end, }, function(choice) if not choice then - -- User cancelled - reject the question M._send_reject(request.id) return end if choice.is_other then - -- Get custom input vim.ui.input({ prompt = 'Enter your response: ' }, function(input) if input and input ~= '' then table.insert(collected_answers, { input }) @@ -98,105 +192,12 @@ function M._show_single_select(request, index, collected_answers, q, items, prom end end) else - -- Reply with selected option table.insert(collected_answers, { choice.label }) M._show_question(request, index + 1, collected_answers) end end) end ---- Show multi-select picker using checkboxes ---- @param request OpencodeQuestionRequest ---- @param index number ---- @param collected_answers string[][] ---- @param q OpencodeQuestionInfo ---- @param items table[] ---- @param prompt string -function M._show_multiselect(request, index, collected_answers, q, items, prompt) - -- For multiselect, we use a simple approach: show items with instructions - -- User can select multiple by choosing "Done" when finished - local selected = {} - - local function show_picker() - local display_items = {} - - for _, item in ipairs(items) do - if not item.is_other then - local prefix = selected[item.label] and '[x] ' or '[ ] ' - table.insert(display_items, { - label = item.label, - description = item.description, - display = prefix .. item.label, - is_other = false, - }) - end - end - - -- Add done and other options - table.insert(display_items, { - label = '-- Done --', - description = 'Confirm selection', - display = '-- Done --', - is_done = true, - }) - table.insert(display_items, { - label = 'Other', - description = 'Provide custom response', - display = 'Other', - is_other = true, - }) - - vim.ui.select(display_items, { - prompt = prompt .. '(multi): ', - format_item = function(item) - if item.description and item.description ~= '' and not item.is_done then - return item.display .. ' - ' .. item.description - end - return item.display - end, - }, function(choice) - if not choice then - M._send_reject(request.id) - return - end - - if choice.is_done then - -- Collect selected items - local answers = {} - for label, _ in pairs(selected) do - table.insert(answers, label) - end - if #answers == 0 then - vim.notify('Please select at least one option', vim.log.levels.WARN) - show_picker() - return - end - table.insert(collected_answers, answers) - M._show_question(request, index + 1, collected_answers) - elseif choice.is_other then - vim.ui.input({ prompt = 'Enter your response: ' }, function(input) - if input and input ~= '' then - table.insert(collected_answers, { input }) - M._show_question(request, index + 1, collected_answers) - else - show_picker() - end - end) - else - -- Toggle selection - if selected[choice.label] then - selected[choice.label] = nil - else - selected[choice.label] = true - end - show_picker() - end - end) - end - - show_picker() -end - --- Send reply to the question --- @param request_id string --- @param answers string[][] From ea755af5840b2983a78749c48f04f814034635ef Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Sun, 11 Jan 2026 17:13:04 -0800 Subject: [PATCH 4/8] refactor(ui): simplify question picker logic by removing fallback --- lua/opencode/ui/question_picker.lua | 48 +---------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua index 685f9240..30f8333a 100644 --- a/lua/opencode/ui/question_picker.lua +++ b/lua/opencode/ui/question_picker.lua @@ -118,7 +118,7 @@ function M._show_question(request, index, collected_answers) vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) -- Use base_picker - local success = base_picker.pick({ + base_picker.pick({ items = items, format_fn = format_option, title = title, @@ -150,52 +150,6 @@ function M._show_question(request, index, collected_answers) -- Multi-select is handled by the confirm_multi action end, }) - - -- Fallback to vim.ui.select if no picker available - if not success then - M._fallback_picker(request, index, collected_answers, q, items) - end -end - ---- Fallback to vim.ui.select when no picker is available ---- @param request OpencodeQuestionRequest ---- @param index number ---- @param collected_answers string[][] ---- @param q OpencodeQuestionInfo ---- @param items table[] -function M._fallback_picker(request, index, collected_answers, q, items) - local question_icon = icons.get('question') or '?' - local progress = #request.questions > 1 and string.format(' (%d/%d)', index, #request.questions) or '' - local prompt = q.header .. progress .. ': ' - - vim.ui.select(items, { - prompt = prompt, - format_item = function(item) - if item.description and item.description ~= '' then - return item.label .. ' - ' .. item.description - end - return item.label - end, - }, function(choice) - if not choice then - M._send_reject(request.id) - return - end - - if choice.is_other then - vim.ui.input({ prompt = 'Enter your response: ' }, function(input) - if input and input ~= '' then - table.insert(collected_answers, { input }) - M._show_question(request, index + 1, collected_answers) - else - M._send_reject(request.id) - end - end) - else - table.insert(collected_answers, { choice.label }) - M._show_question(request, index + 1, collected_answers) - end - end) end --- Send reply to the question From 2f1d34644c5f2811451dc4182131d41898f59e56 Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Sun, 11 Jan 2026 17:52:57 -0800 Subject: [PATCH 5/8] fix(ui): add question to title --- lua/opencode/ui/question_picker.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua index 30f8333a..faa52b20 100644 --- a/lua/opencode/ui/question_picker.lua +++ b/lua/opencode/ui/question_picker.lua @@ -63,17 +63,16 @@ function M._show_question(request, index, collected_answers) is_other = true, }) - -- Build title with question - local question_icon = icons.get('question') or '?' + -- Build title with full question text (no icon) local progress = #questions > 1 and string.format(' (%d/%d)', index, #questions) or '' - local title = question_icon .. ' ' .. q.header .. progress + local title = q.question .. progress -- Define actions local actions = {} if q.multiple then - -- For multi-select, add a confirm action that collects all selections - actions.confirm_multi = { + -- For multi-select, override the default confirm action to collect all selections + actions.confirm = { key = { '', mode = { 'i', 'n' } }, label = 'confirm', multi_selection = true, @@ -115,6 +114,7 @@ function M._show_question(request, index, collected_answers) end -- Show full question as notification for context + local question_icon = icons.get('question') or '?' vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) -- Use base_picker From e0fb4f10fc0327151a43e6f5fa390ec2133174bd Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Mon, 12 Jan 2026 08:39:23 -0800 Subject: [PATCH 6/8] refactor(ui): remove unecessary comments in question and snack pickers --- lua/opencode/ui/base_picker.lua | 2 -- lua/opencode/ui/question_picker.lua | 4 ---- lua/opencode/ui/renderer.lua | 1 - 3 files changed, 7 deletions(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index 06add019..f8c23dd7 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -456,7 +456,6 @@ local function snacks_picker_ui(opts) snack_opts.actions[action_name] = function(_picker, item) if item then - -- Collect selected items before closing (for multi-selection) local items_to_process if action.multi_selection then local selected_items = _picker:selected({ fallback = true }) @@ -465,7 +464,6 @@ local function snacks_picker_ui(opts) items_to_process = item end - -- Close picker unless this is a reload action if not action.reload then _picker:close() end diff --git a/lua/opencode/ui/question_picker.lua b/lua/opencode/ui/question_picker.lua index faa52b20..fc1821f9 100644 --- a/lua/opencode/ui/question_picker.lua +++ b/lua/opencode/ui/question_picker.lua @@ -6,7 +6,6 @@ local config = require('opencode.config') local M = {} --- Track current question being displayed M.current_question = nil --- Format a question option for the picker @@ -30,7 +29,6 @@ function M.show(question) M.current_question = question - -- Process questions sequentially M._show_question(question, 1, {}) end @@ -56,7 +54,6 @@ function M._show_question(request, index, collected_answers) }) end - -- Add "Other" option for custom input table.insert(items, { label = 'Other', description = 'Provide custom response', @@ -67,7 +64,6 @@ function M._show_question(request, index, collected_answers) local progress = #questions > 1 and string.format(' (%d/%d)', index, #questions) or '' local title = q.question .. progress - -- Define actions local actions = {} if q.multiple then diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 67e24a6f..34e1a9ff 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -840,7 +840,6 @@ function M.on_question_asked(properties) return end - -- Only show for current session if not state.active_session or properties.sessionID ~= state.active_session.id then return end From 6151a0b88bcd0204336eaa8779777430fec4b529 Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Mon, 12 Jan 2026 08:45:48 -0800 Subject: [PATCH 7/8] feat(ui): fix cancellation handling in pickers - add logic to ensure callbacks are invoked when selections are made - handle cases where selection is cancelled to call callback with nil - update multiple UI components (telescope, mini_pick, snacks) for consistency - improve user experience by managing selection states effectively --- lua/opencode/ui/base_picker.lua | 44 +++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index f8c23dd7..152e2964 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -123,6 +123,8 @@ local function telescope_ui(opts) ) end + local selection_made = false + current_picker = pickers.new({}, { prompt_title = opts.title, finder = finders.new_table({ results = opts.items, entry_maker = make_entry }), @@ -133,6 +135,7 @@ local function telescope_ui(opts) } or nil, attach_mappings = function(prompt_bufnr, map) actions.select_default:replace(function() + selection_made = true local selection = action_state.get_selected_entry() actions.close(prompt_bufnr) if selection and opts.callback then @@ -140,6 +143,16 @@ local function telescope_ui(opts) end end) + actions.close:enhance({ + post = function() + if not selection_made and opts.callback then + vim.schedule(function() + opts.callback(nil) + end) + end + end, + }) + for _, action in pairs(opts.actions) do if action.key and action.key[1] then local modes = action.key.mode or { 'i', 'n' } @@ -272,6 +285,9 @@ local function fzf_ui(opts) local actions_config = { ['default'] = function(selected, fzf_opts) if not selected or #selected == 0 then + if opts.callback then + opts.callback(nil) + end return end local idx = fzf_opts.fn_fzf_index(selected[1] --[[@as string]]) @@ -279,6 +295,11 @@ local function fzf_ui(opts) opts.callback(opts.items[idx]) end end, + ['esc'] = function() + if opts.callback then + opts.callback(nil) + end + end, } for _, action in pairs(opts.actions) do @@ -363,6 +384,8 @@ local function mini_pick_ui(opts) end end + local selection_made = false + mini_pick.start({ window = opts.width and { @@ -376,10 +399,18 @@ local function mini_pick_ui(opts) name = opts.title, choose = function(selected) if selected and selected.item and opts.callback then + selection_made = true opts.callback(selected.item) end return false end, + on_done = function() + if not selection_made and opts.callback then + vim.schedule(function() + opts.callback(nil) + end) + end + end, }, mappings = mappings, }) @@ -390,7 +421,6 @@ end local function snacks_picker_ui(opts) local Snacks = require('snacks') - -- Determine if preview is enabled local has_preview = opts.preview == 'file' local title = type(opts.title) == 'function' and opts.title() or opts.title @@ -398,6 +428,8 @@ local function snacks_picker_ui(opts) local layout_opts = opts.layout_opts and opts.layout_opts.snacks_layout or nil + local selection_made = false + ---@type snacks.picker.Config local snack_opts = { title = title, @@ -417,7 +449,6 @@ local function snacks_picker_ui(opts) return opts.items end, transform = function(item, ctx) - -- Snacks requires item.text to be set to do matching if not item.text then local picker_item = opts.format_fn(item) item.text = picker_item:to_string() @@ -426,8 +457,16 @@ local function snacks_picker_ui(opts) format = function(item) return opts.format_fn(item):to_formatted_text() end, + on_close = function() + if not selection_made and opts.callback then + vim.schedule(function() + opts.callback(nil) + end) + end + end, actions = { confirm = function(_picker, item) + selection_made = true _picker:close() if item and opts.callback then vim.schedule(function() @@ -465,6 +504,7 @@ local function snacks_picker_ui(opts) end if not action.reload then + selection_made = true _picker:close() end From a2db9dca195746048d09c20e3c377f4c9664a119 Mon Sep 17 00:00:00 2001 From: Aaron Weisberg Date: Mon, 12 Jan 2026 08:47:42 -0800 Subject: [PATCH 8/8] style(docs): update comments guideline in code style section - clarify that comments should explain *why* something is done, not *what* - emphasize avoiding obvious comments that restate code functionality --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c3df645d..bffc763c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ - **Types:** Use Lua annotations (`---@class`, `---@field`, etc.) for public APIs/config. - **Naming:** Modules: `snake_case.lua`; functions/vars: `snake_case`; classes: `CamelCase`. - **Error Handling:** Use `vim.notify` for user-facing errors. Return early on error. -- **Comments:** Only when necessary for clarity. Prefer self-explanatory code. +- **Comments:** Avoid obvious comments that merely restate what the code does. Only add comments when necessary to explain *why* something is done, not *what* is being done. Prefer self-explanatory code. - **Functions:** Prefer local functions. Use `M.func` for module exports. - **Config:** Centralize in `config.lua`. Use deep merge for user overrides. - **Tests:** Place in `tests/minimal/`, `tests/unit/`, or `tests/replay/`. Manual/visual tests in `tests/manual/`.