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.text, "", "", "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", "hd") pcall(vim.api.nvim_del_keymap, "x", "hd") vim.api.nvim_set_keymap("n", "hd", "set operatorfunc=v:lua.hob_todos_operatorg@", { desc = "Hob: address TODOs (motion)" }) vim.api.nvim_set_keymap("x", "hd", "set operatorfunc=v:lua.hob_todos_operatorg@", { desc = "Hob: address TODOs (motion)" }) end return M