diff --git a/README.md b/README.md index d10a6f02..cf4bb865 100644 --- a/README.md +++ b/README.md @@ -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') @@ -618,7 +619,7 @@ The plugin provides the following actions that can be triggered via keymaps, com | **Go to parent session** | `oP` | `:Opencode session parent` | `require('opencode.api').select_parent_session()` | | Open timeline picker (navigate/undo/redo/fork to message) | `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 | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | | Configure model variant | `oV` | `:Opencode variant` / `/variant` | `require('opencode.api').configure_variant()` | | Cycle through model variants | `` (window) | - | `require('opencode.api').cycle_variant()` | diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index f7b181c2..b2c0d58e 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -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, diff --git a/lua/opencode/ui/dialog.lua b/lua/opencode/ui/dialog.lua index 78cfc238..3b4a83a8 100644 --- a/lua/opencode/ui/dialog.lua +++ b/lua/opencode/ui/dialog.lua @@ -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? local Dialog = {} Dialog.__index = Dialog @@ -322,6 +323,8 @@ 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 @@ -329,19 +332,15 @@ function Dialog:format_options(output, options) 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, @@ -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() diff --git a/lua/opencode/ui/inline_input.lua b/lua/opencode/ui/inline_input.lua new file mode 100644 index 00000000..e3cf9c6a --- /dev/null +++ b/lua/opencode/ui/inline_input.lua @@ -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', '', function() + saved_text = nil + close() + opts.on_cancel() + end, { buffer = buf, silent = true, nowait = true }) + + vim.keymap.set('n', '', 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 diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index 2727ab48..837c05c7 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -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 @@ -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()