Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
26 changes: 26 additions & 0 deletions lua/opencode/api_client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpencodeQuestionRequest[]>
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<boolean>
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<boolean>
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
Expand Down
15 changes: 15 additions & 0 deletions lua/opencode/event_manager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 54 additions & 10 deletions lua/opencode/ui/base_picker.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -133,13 +135,24 @@ 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
opts.callback(selection.value)
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' }
Expand Down Expand Up @@ -272,13 +285,21 @@ 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]])
if idx and opts.items[idx] and opts.callback then
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
Expand Down Expand Up @@ -363,6 +384,8 @@ local function mini_pick_ui(opts)
end
end

local selection_made = false

mini_pick.start({
window = opts.width
and {
Expand All @@ -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,
})
Expand All @@ -390,14 +421,15 @@ 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
---@cast title string

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,
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/ui/icons.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ local presets = {
agent = '󰚩 ',
reference = ' ',
reasoning = '󰧑 ',
question = '',
-- statuses
status_on = ' ',
status_off = ' ',
Expand Down Expand Up @@ -64,6 +65,7 @@ local presets = {
attached_file = '@',
agent = '@',
reference = '@',
question = '?',
-- statuses
status_on = 'ON',
status_off = 'OFF',
Expand Down
Loading