local M = {} local source = debug.getinfo(1, "S").source local module_file = assert(source:match("^@(.+)$")) local plugin_dir = vim.fs.dirname(vim.fs.dirname(module_file)) local opencode_config_dir = vim.fs.joinpath(plugin_dir, "opencode") local log = require("hobs.log") local prompt = require("hobs.prompt") local ns = vim.api.nvim_create_namespace("hobs.nvim") local next_hob_id = 0 local config = { opencode = { "opencode", "run" }, opencode_env = { OPENCODE_CONFIG_DIR = opencode_config_dir }, } 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_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, }) vim.api.nvim_buf_add_highlight(state.status_buf, ns, "HobStatus", 0, 0, -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 = anchor_col + 3, width = 22, height = 1, style = "minimal", focusable = false, noautocmd = true, zindex = 50, }) vim.api.nvim_win_set_hl_ns(state.float_win, ns) vim.api.nvim_set_hl(ns, "HobStatus", { fg = "#8b5cf6" }) 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 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.log_hob_sed(state.id, script) log.log_hob_finished(state.id) 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 log.log_error("hob: " .. selection) 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 built_prompt = prompt.build(filename, selection, user_prompt) log.log_hob_started(state.id, filename, user_prompt) local argv = vim.deepcopy(config.opencode) table.insert(argv, built_prompt) local cwd = vim.fn.getcwd() vim.system(argv, { cwd = cwd, text = true, env = config.opencode_env, }, function(result) vim.schedule(function() if result.code ~= 0 then local error_msg = trim(result.stderr or result.stdout or "") set_status(state, "Hob failed") log.log_hob_failed(state.id, error_msg) vim.notify("hobs.nvim: opencode failed:\n" .. error_msg, 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") log.log_hob_edit_failed(state.id, apply_err) 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 local function hob_todos_operator() 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 built_prompt = prompt.build(filename, selection, "address the TODOs in this" .. "selection. Only address the TODOs in the selected text, leave other TODOs" .. "alone.") log.log_hob_started(state.id, filename, "address the TODOs") 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 = anchor_col + 3, width = 22, height = 1, style = "minimal", focusable = false, noautocmd = true, zindex = 50, }) vim.api.nvim_win_set_hl_ns(state.float_win, ns) vim.api.nvim_set_hl(ns, "HobStatus", { fg = "#8b5cf6" }) 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, built_prompt) local cwd = vim.fn.getcwd() vim.system(argv, { cwd = cwd, text = true, env = config.opencode_env, }, function(result) vim.schedule(function() if result.code ~= 0 then local error_msg = trim(result.stderr or result.stdout or "") set_status(state, "Hob failed") log.log_hob_failed(state.id, error_msg) vim.notify("hobs.nvim: opencode failed:\n" .. error_msg, 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") log.log_hob_edit_failed(state.id, apply_err) 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 {}) log.init(ns) 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", log.open, { desc = "Open the hobs.nvim 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.require('hobs').hob_todos_operatorg@", { desc = "Hob: address TODOs (motion)" }) vim.api.nvim_set_keymap("x", "hd", "set operatorfunc=v:lua.require('hobs').hob_todos_operatorg@", { desc = "Hob: address TODOs (motion)" }) end return M