diff --git a/README.md b/README.md index 4a114a9..efa9393 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ Input a prompt for `opencode`. - Press `` to browse recent asks. - Highlights and completes contexts and `opencode` subagents. - Press `` to trigger built-in completion. +- Set `opts.ask.capture = "buffer"` for a larger centered floating buffer. + - Configure via `opts.ask.buffer` (for example: `submit_keys`, `cancel_keys`, `submit_on_write`, `linewrap`). - End the prompt with `\n` to append instead of submit. - Additionally, when using `snacks.input`: - Press `` to append instead of submit. diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 6035a23..9028e4d 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -79,6 +79,7 @@ local defaults = { test = { prompt = "Add tests for @this", submit = true }, }, ask = { + capture = "input", prompt = "Ask opencode: ", completion = "customlist,v:lua.opencode_completion", snacks = { @@ -120,6 +121,25 @@ local defaults = { end, }, }, + buffer = { + width_ratio = 0.7, + height_ratio = 0.3, + min_width = 60, + min_height = 8, + border = "rounded", + title_pos = "center", + linewrap = false, + submit_on_write = false, + start_insert = true, + submit_keys = { + n = { "" }, + i = { "" }, + }, + cancel_keys = { + n = { "q", "" }, + i = { "" }, + }, + }, }, select = { prompt = "opencode: ", diff --git a/lua/opencode/ui/ask/init.lua b/lua/opencode/ui/ask/init.lua index a7edafc..35b520e 100644 --- a/lua/opencode/ui/ask/init.lua +++ b/lua/opencode/ui/ask/init.lua @@ -7,9 +7,205 @@ local M = {} ---Text of the prompt. ---@field prompt? string --- +---Where to capture ask input. +---`"input"` uses `vim.ui.input`. +---`"buffer"` uses a centered floating multi-line buffer. +---@field capture? "input"|"buffer" +--- +---Options for buffer capture mode. +---@field buffer? opencode.ask.BufferOpts +--- ---Options for [`snacks.input`](https://github.com/folke/snacks.nvim/blob/main/docs/input.md). ---@field snacks? snacks.input.Opts +---@class opencode.ask.BufferOpts +---@field width_ratio? number +---@field height_ratio? number +---@field min_width? number +---@field min_height? number +---@field border? string +---@field title_pos? "left"|"center"|"right" +---@field linewrap? boolean +---@field submit_on_write? boolean +---@field start_insert? boolean +---@field submit_keys? table +---@field cancel_keys? table + +---@param keys string|string[]|nil +---@return string[] +local function normalize_keys(keys) + if type(keys) == "string" then + return { keys } + end + if type(keys) == "table" and vim.islist(keys) then + return keys + end + return {} +end + +---@param buf number +---@param mode_keys table|nil +---@param callback function +local function set_mode_keymaps(buf, mode_keys, callback) + if not mode_keys then + return + end + for mode, keys in pairs(mode_keys) do + for _, lhs in ipairs(normalize_keys(keys)) do + vim.keymap.set(mode, lhs, callback, { buffer = buf, nowait = true, silent = true }) + end + end +end + +---@param buf number +---@param context opencode.Context +---@param ns number +---@param agents opencode.cli.client.Agent[] +local function highlight_buffer(buf, context, ns, agents) + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local text = table.concat(lines, "\n") + local rendered = context:render(text, agents) + local extmarks = context.extmarks(rendered.input) + + for _, extmark in ipairs(extmarks) do + vim.api.nvim_buf_set_extmark(buf, ns, (extmark.row or 1) - 1, extmark.col, { + end_col = extmark.end_col, + hl_group = extmark.hl_group, + }) + end +end + +---@param default? string +---@param context opencode.Context +---@param ask_opts opencode.ask.Opts +---@param server opencode.cli.server.Server +---@return Promise +local function buffer_input(default, context, ask_opts, server) + local Promise = require("opencode.promise") + local buffer_opts = ask_opts.buffer or {} + + return Promise.new(function(resolve, reject) + local width = math.max(buffer_opts.min_width or 60, math.floor(vim.o.columns * (buffer_opts.width_ratio or 0.7))) + local height = math.max(buffer_opts.min_height or 8, math.floor(vim.o.lines * (buffer_opts.height_ratio or 0.3))) + local row = math.max(1, math.floor((vim.o.lines - height) / 2) - 1) + local col = math.max(0, math.floor((vim.o.columns - width) / 2)) + + local submit_on_write = buffer_opts.submit_on_write == true + local temp_file = submit_on_write and vim.fn.tempname() or nil + + local buf = vim.api.nvim_create_buf(false, not submit_on_write) + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = buffer_opts.border or "rounded", + title = " " .. (ask_opts.prompt or "Ask opencode: ") .. " ", + title_pos = buffer_opts.title_pos or "center", + }) + + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].filetype = "opencode_ask" + vim.bo[buf].buftype = "" + vim.bo[buf].swapfile = false + + if temp_file then + vim.api.nvim_buf_set_name(buf, temp_file) + end + vim.wo[win].wrap = buffer_opts.linewrap == true + vim.wo[win].linebreak = buffer_opts.linewrap == true + + if buffer_opts.linewrap == true then + vim.keymap.set("n", "j", "gj", { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "k", "gk", { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "0", "g0", { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "^", "g^", { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "$", "g$", { buffer = buf, nowait = true, silent = true }) + end + + local initial = default and vim.split(default, "\n", { plain = true, trimempty = false }) or { "" } + if #initial == 0 then + initial = { "" } + end + if default and default ~= "" and initial[#initial] ~= "" then + table.insert(initial, "") + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, initial) + vim.api.nvim_win_set_cursor(win, { #initial, 0 }) + + local ns = vim.api.nvim_create_namespace("opencode_ask_highlight") + highlight_buffer(buf, context, ns, server.subagents) + + local done = false + local function finish_submit() + if done then + return + end + done = true + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local value = table.concat(lines, "\n") + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + if temp_file then + pcall(vim.fn.delete, temp_file) + end + if value == "" then + reject() + else + resolve(value) + end + end + local function finish_cancel() + if done then + return + end + done = true + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + if temp_file then + pcall(vim.fn.delete, temp_file) + end + reject() + end + + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + buffer = buf, + callback = function() + highlight_buffer(buf, context, ns, server.subagents) + end, + }) + + vim.api.nvim_create_autocmd("WinClosed", { + once = true, + pattern = tostring(win), + callback = finish_cancel, + }) + + if submit_on_write then + vim.api.nvim_create_autocmd("BufWritePost", { + buffer = buf, + callback = finish_submit, + }) + end + + set_mode_keymaps(buf, buffer_opts.submit_keys or { n = { "" }, i = { "" } }, finish_submit) + set_mode_keymaps(buf, buffer_opts.cancel_keys or { n = { "q", "" } }, finish_cancel) + + vim.lsp.start(require("opencode.ui.ask.cmp"), { + bufnr = buf, + }) + + if buffer_opts.start_insert ~= false then + vim.cmd("startinsert") + end + end) +end + ---Prompt for input with `vim.ui.input`, with context- and server-aware completion. --- ---@param default? string Text to pre-fill the input with. @@ -21,6 +217,11 @@ function M.ask(default, context) return require("opencode.cli.server") .get() :next(function(server) ---@param server opencode.cli.server.Server + local ask_opts = require("opencode.config").opts.ask or {} + if ask_opts.capture == "buffer" then + return buffer_input(default, context, ask_opts, server) + end + ---@type snacks.input.Opts local input_opts = { default = default, @@ -29,10 +230,15 @@ function M.ask(default, context) return context.input_highlight(rendered.input) end, } + + local input_ask_opts = vim.deepcopy(ask_opts) + input_ask_opts.capture = nil + input_ask_opts.buffer = nil + -- Nest `snacks.input` options under `opts.ask.snacks` for consistency with other `snacks`-exclusive config, -- and to keep its fields optional. Double-merge is kinda ugly but seems like the lesser evil. - input_opts = vim.tbl_deep_extend("force", input_opts, require("opencode.config").opts.ask) - input_opts = vim.tbl_deep_extend("force", input_opts, require("opencode.config").opts.ask.snacks) + input_opts = vim.tbl_deep_extend("force", input_opts, input_ask_opts) + input_opts = vim.tbl_deep_extend("force", input_opts, ask_opts.snacks or {}) return Promise.input(input_opts) end)