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/`. 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 f9b7fc95..011906c4 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -287,6 +287,24 @@ ---@field tool string Tool name ---@field state { status: string, title?: string } +-- 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/base_picker.lua b/lua/opencode/ui/base_picker.lua index cc6fb05d..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() @@ -456,15 +495,20 @@ 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 + 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 + if not action.reload then + selection_made = true + _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/icons.lua b/lua/opencode/ui/icons.lua index 53baf91f..d8d85318 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..fc1821f9 --- /dev/null +++ b/lua/opencode/ui/question_picker.lua @@ -0,0 +1,181 @@ +-- 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 = {} + +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) + if not question or not question.questions or #question.questions == 0 then + return + end + + M.current_question = question + + 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 + + table.insert(items, { + label = 'Other', + description = 'Provide custom response', + is_other = true, + }) + + -- Build title with full question text (no icon) + local progress = #questions > 1 and string.format(' (%d/%d)', index, #questions) or '' + local title = q.question .. progress + + local actions = {} + + if q.multiple then + -- For multi-select, override the default confirm action to collect all selections + actions.confirm = { + 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 + local question_icon = icons.get('question') or '?' + vim.notify(question_icon .. ' ' .. q.question, vim.log.levels.INFO, { title = 'OpenCode Question' }) + + -- Use base_picker + 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, + }) +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..34e1a9ff 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,24 @@ 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 + + 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