diff options
Diffstat (limited to 'lua')
| -rw-r--r-- | lua/hobs.lua | 577 |
1 files changed, 577 insertions, 0 deletions
diff --git a/lua/hobs.lua b/lua/hobs.lua new file mode 100644 index 0000000..85acec7 --- /dev/null +++ b/lua/hobs.lua @@ -0,0 +1,577 @@ +local M = {} + +local ns = vim.api.nvim_create_namespace("hobs.nvim") +local next_hob_id = 0 +local log_buf = nil + +local config = { + opencode = { "opencode", "run" }, +} + +local function trim(s) + return (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function strip_markdown_fence(s) + s = s:gsub("\r\n", "\n") + s = trim(s) + + local body = s:match("^```[%w_-]*\n(.*)\n```$") + if body then + return body + end + + return s +end + +local function get_log_buf() + if log_buf and vim.api.nvim_buf_is_valid(log_buf) then + return log_buf + end + + log_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(log_buf, "hobs://log") + vim.bo[log_buf].buftype = "nofile" + vim.bo[log_buf].bufhidden = "hide" + vim.bo[log_buf].swapfile = false + vim.bo[log_buf].filetype = "sed" + vim.bo[log_buf].modifiable = false + return log_buf +end + +local function append_log(lines) + local buf = get_log_buf() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines) + vim.bo[buf].modifiable = false +end + +local function log_sed(state, script) + local filename = vim.api.nvim_buf_get_name(state.buf) + local lines = { + string.format("===== Hob %d | %s | %s =====", state.id, os.date("%Y-%m-%d %H:%M:%S"), filename), + } + vim.list_extend(lines, vim.split(script, "\n", { plain = true })) + table.insert(lines, "") + append_log(lines) +end + +local function open_log() + local buf = get_log_buf() + vim.cmd("botright split") + vim.api.nvim_win_set_buf(0, buf) +end + +local function get_visual_selection(buf) + local mode = vim.fn.visualmode() + if mode == "\22" then + error("blockwise visual selections are not supported yet") + end + + local start = vim.fn.getpos("'<") + local finish = vim.fn.getpos("'>") + + if start[2] == 0 or finish[2] == 0 then + error("no visual selection found") + end + + local start_row = start[2] - 1 + local end_row = finish[2] - 1 + local start_col = start[3] - 1 + local end_col + + if mode == "V" then + start_col = 0 + local line = vim.api.nvim_buf_get_lines(buf, end_row, end_row + 1, false)[1] or "" + end_col = #line + else + -- '< and '> point at the selected characters; nvim_buf_get_text uses an + -- exclusive end column. + end_col = finish[3] - 1 + if vim.o.selection ~= "exclusive" then + end_col = end_col + 1 + end + end + + local lines = vim.api.nvim_buf_get_text( + buf, + start_row, + start_col, + end_row, + end_col, + {} + ) + + return { + mode = mode, + start_row = start_row, + start_col = start_col, + end_row = end_row, + end_col = end_col, + display_start_line = start[2], + display_start_col = start[3], + display_end_line = finish[2], + display_end_col = finish[3], + text = table.concat(lines, "\n"), + } +end + +local function close_status(state) + if state.augroup then + pcall(vim.api.nvim_del_augroup_by_id, state.augroup) + state.augroup = nil + end + + if state.float_win and vim.api.nvim_win_is_valid(state.float_win) then + pcall(vim.api.nvim_win_close, state.float_win, true) + end + state.float_win = nil + + if state.status_buf and vim.api.nvim_buf_is_valid(state.status_buf) then + pcall(vim.api.nvim_buf_delete, state.status_buf, { force = true }) + end + state.status_buf = nil + + if vim.api.nvim_buf_is_valid(state.buf) then + pcall(vim.api.nvim_buf_del_extmark, state.buf, ns, state.mark) + pcall(vim.api.nvim_buf_del_extmark, state.buf, ns, state.anchor_mark) + end +end + +local function anchor_position(state) + if not vim.api.nvim_buf_is_valid(state.buf) then + return nil + end + + local pos = vim.api.nvim_buf_get_extmark_by_id(state.buf, ns, state.anchor_mark, {}) + + if #pos == 0 then + return nil + end + + return { pos[1], pos[2] } +end + +local function update_float_position(state) + if not state.float_win or not vim.api.nvim_win_is_valid(state.float_win) then + return + end + + if not vim.api.nvim_win_is_valid(state.source_win) then + pcall(vim.api.nvim_win_close, state.float_win, true) + state.float_win = nil + return + end + + local pos = anchor_position(state) + if not pos then + return + end + + pcall(vim.api.nvim_win_set_config, state.float_win, { + relative = "win", + win = state.source_win, + bufpos = pos, + row = 0, + col = 0, + }) +end + +local function set_status(state, text) + if not state.status_buf or not vim.api.nvim_buf_is_valid(state.status_buf) then + return + end + + local line = " " .. text .. " " + vim.api.nvim_buf_set_lines(state.status_buf, 0, -1, false, { line }) + + if state.float_win and vim.api.nvim_win_is_valid(state.float_win) then + pcall(vim.api.nvim_win_set_config, state.float_win, { + width = math.max(16, vim.fn.strdisplaywidth(line)), + height = 1, + }) + end +end + +local function create_status(buf, source_win, selection) + next_hob_id = next_hob_id + 1 + + local mark = vim.api.nvim_buf_set_extmark(buf, ns, selection.start_row, selection.start_col, { + end_row = selection.end_row, + end_col = selection.end_col, + right_gravity = false, + end_right_gravity = true, + }) + + -- The status float lives on the first selected row, immediately after the + -- selected text on that row. Keep this as its own extmark so the anchor + -- continues to track edits independently of the selection range itself. + local first_line = vim.api.nvim_buf_get_lines(buf, selection.start_row, selection.start_row + 1, false)[1] or "" + local anchor_col + if selection.start_row == selection.end_row then + anchor_col = selection.end_col + else + anchor_col = #first_line + end + + local anchor_mark = vim.api.nvim_buf_set_extmark(buf, ns, selection.start_row, anchor_col, { + right_gravity = true, + }) + + local state = { + id = next_hob_id, + buf = buf, + source_win = source_win, + mark = mark, + anchor_mark = anchor_mark, + } + + state.status_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.status_buf].bufhidden = "wipe" + + local pos = anchor_position(state) or { selection.start_row, anchor_col } + state.float_win = vim.api.nvim_open_win(state.status_buf, false, { + relative = "win", + win = source_win, + bufpos = pos, + row = 0, + col = 0, + width = 22, + height = 1, + style = "minimal", + focusable = false, + noautocmd = true, + zindex = 50, + }) + + vim.api.nvim_set_option_value("winhighlight", "WinBg:HobStatusBg", { win = state.float_win }) + vim.api.nvim_win_set_hl_ns(state.float_win, ns) + vim.api.nvim_set_hl(ns, "HobStatusBg", { bg = "darkgrey" }) + + state.augroup = vim.api.nvim_create_augroup("hobs.nvim." .. state.id, { clear = true }) + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "InsertLeave" }, { + group = state.augroup, + buffer = buf, + callback = function() + vim.schedule(function() + update_float_position(state) + end) + end, + }) + + set_status(state, "Hob is working…") + return state +end + +local function build_prompt(filename, selection, user_prompt) + return table.concat({ + "You are a coding agent working concurrently with a human in Neovim.", + "", + "The human selected this code and gave you a task.", + "The buffer may continue changing while you work.", + "", + "File: " .. filename, + string.format( + "Initial selection: %d:%d-%d:%d", + selection.display_start_line, + selection.display_start_col, + selection.display_end_line, + selection.display_end_col + ), + "", + "Selected text:", + "<selection>", + selection.text, + "</selection>", + "", + "Task:", + user_prompt, + "", + "IMPORTANT OUTPUT CONTRACT:", + "- Do not edit any files yourself.", + "- You may inspect the project as needed, but your final response must be ONLY a GNU sed script.", + "- The sed script will be applied to the CURRENT live contents of this Neovim buffer after you finish.", + "- Do not use absolute line-number addresses; the human may have inserted or removed lines while you were working.", + "- Address edits by distinctive source text/patterns and keep substitutions narrowly scoped.", + "- Prefer scripts that fail to change anything rather than matching unrelated code.", + "- Do not include Markdown fences, prose, explanations, or shell commands.", + "- Output only the contents of a sed -f program.", + }, "\n") +end + +local function current_buffer_text(buf) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + return table.concat(lines, "\n") .. "\n" +end + +local function apply_sed(state, script) + if not vim.api.nvim_buf_is_valid(state.buf) then + error("the source buffer no longer exists") + end + + script = strip_markdown_fence(script) + if script == "" then + error("OpenCode returned an empty sed script") + end + + log_sed(state, script) + + local sedfile = vim.fn.tempname() .. ".sed" + vim.fn.writefile(vim.split(script, "\n", { plain = true }), sedfile, "b") + + -- Validate the script against the exact live buffer that we are about to + -- filter. This prevents a sed syntax error from destroying the buffer via + -- :%! with empty/partial stdout. + local validation = vim.system({ "sed", "-f", sedfile }, { + stdin = current_buffer_text(state.buf), + text = true, + }):wait() + + if validation.code ~= 0 then + vim.fn.delete(sedfile) + error("sed rejected the generated script:\n" .. trim(validation.stderr or "")) + end + + set_status(state, "Hob finished; applying edit…") + + local ok, err = pcall(function() + vim.api.nvim_buf_call(state.buf, function() + vim.cmd("silent keepjumps %!sed -f " .. vim.fn.fnameescape(sedfile)) + end) + end) + + vim.fn.delete(sedfile) + + if not ok then + error(err) + end +end + +function M.hob(user_prompt) + local buf = vim.api.nvim_get_current_buf() + local source_win = vim.api.nvim_get_current_win() + + local ok, selection = pcall(get_visual_selection, buf) + if not ok then + vim.notify("hobs.nvim: " .. selection, vim.log.levels.ERROR) + return + end + + local filename = vim.api.nvim_buf_get_name(buf) + if filename == "" then + filename = "[unnamed buffer]" + end + + local state = create_status(buf, source_win, selection) + local prompt = build_prompt(filename, selection, user_prompt) + + local argv = vim.deepcopy(config.opencode) + table.insert(argv, prompt) + + local cwd = vim.fn.getcwd() + + vim.system(argv, { + cwd = cwd, + text = true, + }, function(result) + vim.schedule(function() + if result.code ~= 0 then + set_status(state, "Hob failed") + vim.notify( + "hobs.nvim: opencode failed:\n" .. trim(result.stderr or result.stdout or ""), + vim.log.levels.ERROR + ) + vim.defer_fn(function() + close_status(state) + end, 2500) + return + end + + local applied, apply_err = pcall(apply_sed, state, result.stdout or "") + if not applied then + set_status(state, "Hob edit failed") + vim.notify("hobs.nvim: " .. apply_err, vim.log.levels.ERROR) + vim.defer_fn(function() + close_status(state) + end, 3500) + return + end + + set_status(state, "Hob done") + vim.defer_fn(function() + close_status(state) + end, 1200) + end) + end) +end + +_G.hob_todos_operator = function () + local sel = vim.fn.getpos(".") + local line_start = sel[2] + 1 + + vim.fn.setpos(".", { 0, sel[2] + sel[4], sel[3] - 1 }) + local end_pos = vim.fn.getpos(".") + local line_end = end_pos[2] + + if line_start > line_end then + local tmp = line_start + line_start = line_end + line_end = tmp + end + + vim.fn.setpos(".", { 0, sel[2], sel[3] - 1 }) + + local buf = vim.api.nvim_get_current_buf() + local source_win = vim.api.nvim_get_current_win() + + local lines = vim.api.nvim_buf_get_lines(buf, line_start - 1, line_end, false) + local text = table.concat(lines, "\n") + + local selection = { + start_row = line_start - 1, + start_col = 0, + end_row = line_end - 1, + end_col = #lines[#lines] or 0, + display_start_line = line_start, + display_start_col = 1, + display_end_line = line_end, + display_end_col = #lines[#lines] or 1, + text = text, + } + + local filename = vim.api.nvim_buf_get_name(buf) or "[unnamed buffer]" + local prompt = build_prompt(filename, selection, "address the TODOs in this" .. + "selection. Only address the TODOs in the selected text, leave other TODOs" .. + "alone.") + + local state = { + id = next_hob_id, + buf = buf, + source_win = source_win, + } + + next_hob_id = next_hob_id + 1 + state.id = next_hob_id + + local mark = vim.api.nvim_buf_set_extmark(buf, ns, selection.start_row, selection.start_col, { + end_row = selection.end_row, + end_col = selection.end_col, + right_gravity = false, + end_right_gravity = true, + }) + + local first_line = vim.api.nvim_buf_get_lines(buf, selection.start_row, selection.start_row + 1, false)[1] or "" + local anchor_col + if selection.start_row == selection.end_row then + anchor_col = selection.end_col + else + anchor_col = #first_line + end + + local anchor_mark = vim.api.nvim_buf_set_extmark(buf, ns, selection.start_row, anchor_col, { + right_gravity = true, + }) + + state.mark = mark + state.anchor_mark = anchor_mark + + state.status_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.status_buf].bufhidden = "wipe" + + local pos = anchor_position(state) or { selection.start_row, anchor_col } + state.float_win = vim.api.nvim_open_win(state.status_buf, false, { + relative = "win", + win = source_win, + bufpos = pos, + row = 0, + col = 0, + width = 22, + height = 1, + style = "minimal", + focusable = false, + noautocmd = true, + zindex = 50, + }) + + vim.api.nvim_set_option_value("winhighlight", "WinBg:HobStatusBg", { win = state.float_win }) + vim.api.nvim_win_set_hl_ns(state.float_win, ns) + vim.api.nvim_set_hl(ns, "HobStatusBg", { bg = "darkgrey" }) + + state.augroup = vim.api.nvim_create_augroup("hobs.nvim." .. state.id, { clear = true }) + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "InsertLeave" }, { + group = state.augroup, + buffer = buf, + callback = function() + vim.schedule(function() + update_float_position(state) + end) + end, + }) + + set_status(state, "Hob is working…") + + local argv = vim.deepcopy(config.opencode) + table.insert(argv, prompt) + + local cwd = vim.fn.getcwd() + + vim.system(argv, { + cwd = cwd, + text = true, + }, function(result) + vim.schedule(function() + if result.code ~= 0 then + set_status(state, "Hob failed") + vim.notify( + "hobs.nvim: opencode failed:\n" .. trim(result.stderr or result.stdout or ""), + vim.log.levels.ERROR + ) + vim.defer_fn(function() + close_status(state) + end, 2500) + return + end + + local applied, apply_err = pcall(apply_sed, state, result.stdout or "") + if not applied then + set_status(state, "Hob edit failed") + vim.notify("hobs.nvim: " .. apply_err, vim.log.levels.ERROR) + vim.defer_fn(function() + close_status(state) + end, 3500) + return + end + + set_status(state, "Hob done") + vim.defer_fn(function() + close_status(state) + end, 1200) + end) + end) +end + +function M.setup(opts) + config = vim.tbl_deep_extend("force", config, opts or {}) + + pcall(vim.api.nvim_del_user_command, "Hob") + vim.api.nvim_create_user_command("Hob", function(opts_) + M.hob(opts_.args) + end, { + nargs = "+", + range = true, + desc = "Send the visual selection to a Hob", + }) + + pcall(vim.api.nvim_del_user_command, "HobLog") + vim.api.nvim_create_user_command("HobLog", open_log, { + desc = "Open the hobs.nvim sed log", + }) + + pcall(vim.api.nvim_del_keymap, "n", "<leader>hd") + pcall(vim.api.nvim_del_keymap, "x", "<leader>hd") + vim.api.nvim_set_keymap("n", "<leader>hd", "<cmd>set operatorfunc=v:lua.hob_todos_operator<CR>g@", { desc = "Hob: address TODOs (motion)" }) + vim.api.nvim_set_keymap("x", "<leader>hd", "<cmd>set operatorfunc=v:lua.hob_todos_operator<CR>g@", { desc = "Hob: address TODOs (motion)" }) +end + +return M + |