Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ require('opencode').setup({
},
questions = {
use_vim_ui_select = false, -- If true, render questions/prompts with vim.ui.select instead of showing them inline in the output buffer.
inline_other_input = true, -- If true, show an inline floating input for "Other" instead of vim.ui.input.
},
output = {
filetype = 'opencode_output', -- Filetype assigned to the output buffer (default: 'opencode_output')
Expand Down Expand Up @@ -618,7 +619,7 @@ The plugin provides the following actions that can be triggered via keymaps, com
| **Go to parent session** | `<leader>oP` | `:Opencode session parent` | `require('opencode.api').select_parent_session()` |
| Open timeline picker (navigate/undo/redo/fork to message) | `<leader>oT` | `:Opencode timeline` | `require('opencode.api').timeline()` |
| Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` |
| Jump to file referenced at cursor in output window | `gf` (window) | `:Opencode jump_to_file` / `/jump_to_file` | `require('opencode.api').jump_to_file()` |
| Jump to file referenced at cursor in output window | `gf` (window) | `:Opencode jump_to_file` / `/jump_to_file` | `require('opencode.api').jump_to_file()` |
| Configure provider and model | `<leader>op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` |
| Configure model variant | `<leader>oV` | `:Opencode variant` / `/variant` | `require('opencode.api').configure_variant()` |
| Cycle through model variants | `<M-r>` (window) | - | `require('opencode.api').cycle_variant()` |
Expand Down
1 change: 1 addition & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ M.defaults = {
},
questions = {
use_vim_ui_select = false, -- If true, render questions with vim.ui.select instead of in the output buffer
inline_other_input = true, -- If true, show an inline floating input for "Other" instead of cmdline prompt
},
input = {
min_height = 0.10,
Expand Down
29 changes: 19 additions & 10 deletions lua/opencode/ui/dialog.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
---@field private _selected_index integer Currently selected option index
---@field private _active boolean Whether dialog is currently active
---@field private _group_index integer Currently selected group index
---@field private _option_positions table<integer, {line: integer, col: integer}>?
local Dialog = {}
Dialog.__index = Dialog

Expand Down Expand Up @@ -322,26 +323,24 @@ end
---@param output Output Output object to write to
---@param options table[] Array of option objects with {label: string, description?: string}
function Dialog:format_options(output, options)
self._option_positions = {}

for i, option in ipairs(options) do
local label = option.label
if option.description and option.description ~= '' then
label = label .. ' - ' .. option.description
end

local is_selected = self._selected_index == i
local line_text = is_selected and string.format(' %d. %s ', i, label) or string.format(' %d. %s', i, label)

-- Output uses 0-based indexing for extmarks. The correct target for
-- extmarks is the previous line count (0-based) because add_line will
-- append a new line and increase the 1-based line count. Capture the
-- current count first and then add the line so we can use that 0-based
-- index for extmarks.
-- add_line returns a 1-based line index; Output extmarks use 0-based
-- keys, so subtract 1 to get the correct extmark key.
local prefix = string.format(' %d. ', i)
local line_text = is_selected and (prefix .. label .. ' ') or (prefix .. label)

local added_idx = output:add_line(line_text)
local extmark_idx = added_idx - 1

self._option_positions[i] = { line = extmark_idx, col = #prefix }

if is_selected then
local extmark_idx = added_idx - 1
output:add_extmark(extmark_idx, { line_hl_group = 'OpencodeDialogOptionHover' } --[[@as OutputExtmark]])
output:add_extmark(extmark_idx, {
start_col = 2,
Expand All @@ -352,6 +351,16 @@ function Dialog:format_options(output, options)
end
end

---Get the (line, col) of a rendered option from the most recent
---format_options call. 0-based, relative to this dialog's Output block —
---callers must add the containing part's `line_start` (from render_state)
---to get an absolute buffer row.
---@param index integer
---@return { line: integer, col: integer }|nil
function Dialog:get_option_position(index)
return self._option_positions and self._option_positions[index]
end

---Set up buffer-scoped keymaps
function Dialog:_setup_keymaps()
self:_clear_keymaps()
Expand Down
134 changes: 134 additions & 0 deletions lua/opencode/ui/inline_input.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
local M = {}

---@type string|nil
local saved_text = nil

---@class InlineInputOpts
---@field win integer -- window to anchor against
---@field row integer -- 0-indexed row in that window's buffer
---@field col integer -- 0-indexed col in that window's buffer
---@field min_width? integer
---@field max_width? integer
---@field title? string -- window border title
---@field on_submit fun(text: string)
---@field on_cancel fun()

---Open a floating, prompt-buffer-backed text input anchored at a specific
---(row, col) inside an existing window's buffer, so it visually appears
---"inline" at that position rather than as a separate cmdline prompt.
---@param opts InlineInputOpts
---@return { close: fun(), win: integer, buf: integer }
function M.open(opts)
local min_width = opts.min_width or 50
local max_width = opts.max_width or 75

local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = 'prompt'
vim.bo[buf].bufhidden = 'wipe'
vim.fn.prompt_setprompt(buf, '')

if saved_text and saved_text ~= '' then
vim.api.nvim_buf_set_lines(buf, 0, 1, false, { saved_text })
saved_text = nil
end

local win = vim.api.nvim_open_win(buf, true, {
relative = 'win',
win = opts.win,
bufpos = { opts.row, opts.col },
width = min_width,
height = 1,
style = 'minimal',
border = 'rounded',
title = opts.title and (' ' .. opts.title .. ' ') or nil,
title_pos = 'left',
zindex = 60,
})

local closed = false
local function close()
if closed then
return
end
closed = true
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
if vim.api.nvim_win_is_valid(opts.win) then
vim.api.nvim_set_current_win(opts.win)
end
end

vim.fn.prompt_setcallback(buf, function(text)
close()
if text ~= '' then
saved_text = nil
opts.on_submit(text)
else
opts.on_cancel()
end
end)

vim.keymap.set('i', '<C-c>', function()
saved_text = nil
close()
opts.on_cancel()
end, { buffer = buf, silent = true, nowait = true })

vim.keymap.set('n', '<Esc>', function()
saved_text = nil
close()
opts.on_cancel()
end, { buffer = buf, silent = true, nowait = true })

vim.api.nvim_create_autocmd('TextChangedI', {
buffer = buf,
callback = function()
if not vim.api.nvim_win_is_valid(win) then
return
end
local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ''
local width = math.min(max_width, math.max(min_width, vim.fn.strdisplaywidth(line) + 2))
vim.api.nvim_win_set_config(win, { width = width })
end,
})

vim.api.nvim_create_autocmd('WinClosed', {
pattern = tostring(win),
callback = function()
if closed then
return
end
closed = true
if vim.api.nvim_win_is_valid(opts.win) then
vim.api.nvim_set_current_win(opts.win)
end
opts.on_cancel()
end,
})

vim.api.nvim_create_autocmd('WinLeave', {
buffer = buf,
callback = function()
if not closed then
local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ''
if line ~= '' then
saved_text = line
end
close()
opts.on_cancel()
end
end,
})

vim.schedule(function()
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_set_current_win(win)
vim.cmd.startinsert()
end
end)

return { close = close, win = win, buf = buf }
end

return M
47 changes: 47 additions & 0 deletions lua/opencode/ui/question_window.lua
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,48 @@ function M.format_display(output)
})
end

---@param index integer
---@return boolean
local function handle_other_option_inline(index)
local question_info = M.get_current_question_info()
local other_index = question_info and find_other_option(question_info.options)
local total_options = question_info and get_total_options(question_info)
local is_other = question_info
and ((other_index and index == other_index) or (not other_index and index == total_options))

if not (is_other and config.ui.questions.inline_other_input ~= false) then
return false
end

local pos = M._dialog and M._dialog:get_option_position(index)
local part_data = require('opencode.ui.renderer.ctx').render_state:get_part('question-display-part')

if not (pos and part_data and part_data.line_start and state.windows and state.windows.output_win) then
return false
end

require('opencode.ui.inline_input').open({
win = state.windows.output_win,
row = part_data.line_start + pos.line,
col = pos.col,
title = 'Type your answer',
on_submit = function(text)
if text and text ~= '' then
M._answering = true
render_question()
M._clear_dialog()
answer_current_question(text)
else
render_question()
end
end,
on_cancel = function()
render_question()
end,
})
return true
end

---Create the in-buffer dialog used to answer the active question.
function M._setup_dialog()
if not M.has_question() then
Expand All @@ -457,6 +499,11 @@ function M._setup_dialog()
if not check_focused() then
return
end

if handle_other_option_inline(index) then
return
end

M._answering = true
render_question()
M._clear_dialog()
Expand Down
Loading