diff options
| author | Josh Rahm <rahm@josher.dev> | 2026-08-11 11:19:05 -0600 |
|---|---|---|
| committer | Josh Rahm <rahm@josher.dev> | 2026-08-11 11:19:05 -0600 |
| commit | d7829c069b09d2115daa1aa33a729dd9ffee301e (patch) | |
| tree | 4b4845c79c0df4b3879249e83ebbea44239f5cef /lua | |
| parent | d2eadb42ffde7f07b4c2d46efc3724d565ec8255 (diff) | |
| download | hobs.nvim-d7829c069b09d2115daa1aa33a729dd9ffee301e.tar.gz hobs.nvim-d7829c069b09d2115daa1aa33a729dd9ffee301e.tar.bz2 hobs.nvim-d7829c069b09d2115daa1aa33a729dd9ffee301e.zip | |
[refactor] break up some of the mega lua file.
Diffstat (limited to 'lua')
| -rw-r--r-- | lua/hobs.lua | 147 | ||||
| -rw-r--r-- | lua/hobs/log.lua | 170 | ||||
| -rw-r--r-- | lua/hobs/prompt.lua | 39 | ||||
| -rw-r--r-- | lua/hobs/util.lua | 101 |
4 files changed, 356 insertions, 101 deletions
diff --git a/lua/hobs.lua b/lua/hobs.lua index 85acec7..885004d 100644 --- a/lua/hobs.lua +++ b/lua/hobs.lua @@ -1,11 +1,19 @@ 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 log_buf = nil local config = { opencode = { "opencode", "run" }, + opencode_env = { OPENCODE_CONFIG_DIR = opencode_config_dir }, } local function trim(s) @@ -24,43 +32,6 @@ local function strip_markdown_fence(s) 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() @@ -190,6 +161,7 @@ local function set_status(state, text) 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 @@ -235,7 +207,7 @@ local function create_status(buf, source_win, selection) win = source_win, bufpos = pos, row = 0, - col = 0, + col = anchor_col + 3, width = 22, height = 1, style = "minimal", @@ -244,9 +216,8 @@ local function create_status(buf, source_win, selection) 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" }) + 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" }, { @@ -263,42 +234,6 @@ local function create_status(buf, source_win, selection) 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" @@ -314,7 +249,8 @@ local function apply_sed(state, script) error("OpenCode returned an empty sed script") end - log_sed(state, script) + 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") @@ -353,6 +289,7 @@ function M.hob(user_prompt) 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 @@ -363,24 +300,26 @@ function M.hob(user_prompt) end local state = create_status(buf, source_win, selection) - local prompt = build_prompt(filename, selection, user_prompt) + 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, prompt) + 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") - vim.notify( - "hobs.nvim: opencode failed:\n" .. trim(result.stderr or result.stdout or ""), - vim.log.levels.ERROR - ) + 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) @@ -390,6 +329,7 @@ function M.hob(user_prompt) 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) @@ -405,7 +345,7 @@ function M.hob(user_prompt) end) end -_G.hob_todos_operator = function () +local function hob_todos_operator() local sel = vim.fn.getpos(".") local line_start = sel[2] + 1 @@ -440,9 +380,11 @@ _G.hob_todos_operator = function () } 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 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, @@ -484,7 +426,7 @@ _G.hob_todos_operator = function () win = source_win, bufpos = pos, row = 0, - col = 0, + col = anchor_col + 3, width = 22, height = 1, style = "minimal", @@ -493,9 +435,8 @@ _G.hob_todos_operator = function () 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" }) + 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" }, { @@ -511,21 +452,21 @@ _G.hob_todos_operator = function () set_status(state, "Hob is working…") local argv = vim.deepcopy(config.opencode) - table.insert(argv, prompt) + 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") - vim.notify( - "hobs.nvim: opencode failed:\n" .. trim(result.stderr or result.stdout or ""), - vim.log.levels.ERROR - ) + 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) @@ -535,6 +476,7 @@ _G.hob_todos_operator = function () 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) @@ -553,6 +495,8 @@ 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) @@ -563,15 +507,16 @@ function M.setup(opts) }) pcall(vim.api.nvim_del_user_command, "HobLog") - vim.api.nvim_create_user_command("HobLog", open_log, { - desc = "Open the hobs.nvim sed log", + vim.api.nvim_create_user_command("HobLog", log.open, { + desc = "Open the hobs.nvim 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)" }) + vim.api.nvim_set_keymap("n", "<leader>hd", "<cmd>set operatorfunc=v:lua.require('hobs').hob_todos_operator<CR>g@", + { desc = "Hob: address TODOs (motion)" }) + vim.api.nvim_set_keymap("x", "<leader>hd", "<cmd>set operatorfunc=v:lua.require('hobs').hob_todos_operator<CR>g@", + { desc = "Hob: address TODOs (motion)" }) end return M - diff --git a/lua/hobs/log.lua b/lua/hobs/log.lua new file mode 100644 index 0000000..7371dfb --- /dev/null +++ b/lua/hobs/log.lua @@ -0,0 +1,170 @@ +local M = {} + +local ns = nil +local log_buf = nil +local log_ns = nil + +function M.init(hobs_ns) + ns = hobs_ns + log_ns = vim.api.nvim_create_namespace("hobs.nvim.log") + + vim.api.nvim_set_hl(log_ns, "HobLogTimestamp", { fg = "grey", bold = true }) + vim.api.nvim_set_hl(log_ns, "HobLogTagStarted", { fg = "#4ade80", bold = true }) + vim.api.nvim_set_hl(log_ns, "HobLogTagFinished", { fg = "#60a5fa", bold = true }) + vim.api.nvim_set_hl(log_ns, "HobLogTagFailed", { fg = "#f87171", bold = true }) + vim.api.nvim_set_hl(log_ns, "HobLogTagError", { fg = "#fb923c", bold = true }) + vim.api.nvim_set_hl(log_ns, "HobLogSed", { fg = "#a78bfa" }) + vim.api.nvim_set_hl(log_ns, "HobLogSeparator", { fg = "#71717a" }) +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 = "log" + vim.bo[log_buf].modifiable = false + + return log_buf +end + +local function append_to_log(lines, hl_group) + local buf = get_log_buf() + local base_row = vim.api.nvim_buf_line_count(buf) + + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines) + vim.bo[buf].modifiable = false + + if hl_group then + local end_row = vim.api.nvim_buf_line_count(buf) - 1 + for row = base_row, end_row do + vim.api.nvim_buf_add_highlight(buf, log_ns, hl_group, row, 0, -1) + end + end +end + +local function timestamp() + return os.date("%Y-%m-%d %H:%M:%S") +end + +local function log_entry(tag, msg, hl_group) + local lines = vim.split(msg, "\n", { plain = true }) + + local prefix = string.format("[%s] %s:", timestamp(), tag) + lines[1] = prefix .. " " .. lines[1] + for i = 2, #lines do + lines[i] = "" .. lines[i] + end + + if hl_group and #lines == 1 then + local buf = get_log_buf() + local base_row = vim.api.nvim_buf_line_count(buf) + + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, -1, -1, false, lines) + + local tag_end_col = #prefix + vim.api.nvim_buf_add_highlight(buf, log_ns, "HobLogTimestamp", base_row, 0, tag_end_col) + vim.api.nvim_buf_add_highlight(buf, log_ns, hl_group, base_row, tag_end_col, -1) + + vim.bo[buf].modifiable = false + else + append_to_log(lines) + end +end + +function M.log_info(msg, hl_group) + append_to_log({ string.format("[%s] INFO: %s", timestamp(), msg) }, hl_group) +end + +function M.log_error(msg) + log_entry("ERROR", msg, "HobLogTagError") +end + +function M.log_hob_started(id, filename, prompt) + local filename_short = vim.fn.fnamemodify(filename, ":t") + local msg = string.format("Hob %d started | %s | %s", id, filename_short, prompt) + log_entry("HOB", msg, "HobLogTagStarted") +end + +function M.log_hob_working(id, status) + log_entry("HOB", string.format("Hob %d: %s", id, status)) +end + +function M.log_hob_sed(id, script) + local buf = get_log_buf() + + vim.bo[buf].modifiable = true + local base_row = vim.api.nvim_buf_line_count(buf) + + local separator_line = string.format("===== Hob %d sed script =====", id) + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { separator_line }) + vim.api.nvim_buf_add_highlight(buf, log_ns, "HobLogSeparator", base_row, 0, -1) + + local sed_lines = vim.split(script, "\n", { plain = true }) + if #sed_lines > 0 then + vim.api.nvim_buf_set_lines(buf, -1, -1, false, sed_lines) + + for i = 1, #sed_lines do + vim.api.nvim_buf_add_highlight(buf, log_ns, "HobLogSed", base_row + i - 1, 0, -1) + end + end + + local end_sep_row = vim.api.nvim_buf_line_count(buf) - 1 + vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "===== end sed script =====" }) + vim.api.nvim_buf_add_highlight(buf, log_ns, "HobLogSeparator", end_sep_row, 0, -1) + + vim.bo[buf].modifiable = false +end + +function M.log_hob_finished(id) + log_entry("HOB", string.format("Hob %d finished and applied edit", id), "HobLogTagFinished") +end + +function M.log_hob_failed(id, reason) + log_entry("HOB", string.format("Hob %d failed: %s", id, reason or "unknown error"), "HobLogTagFailed") +end + +function M.log_hob_edit_failed(id, reason) + log_entry("HOB", string.format("Hob %d edit failed: %s", id, reason or "unknown error"), "HobLogTagFailed") +end + +function M.open() + local buf = get_log_buf() + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + + local win = vim.api.nvim_get_current_win() + local win_height = vim.api.nvim_win_get_height(win) + + vim.cmd("botright split") + vim.api.nvim_win_set_buf(0, buf) + + local log_height = math.min(15, math.ceil(win_height * 0.4)) + vim.api.nvim_win_set_height(0, log_height) + vim.api.nvim_win_set_hl_ns(0, log_ns) + + local lines_count = vim.api.nvim_buf_line_count(buf) + if lines_count > 0 then + local col = vim.fn.min({ vim.api.nvim_win_get_width(0) - 1, vim.fn.strwidth(vim.api.nvim_get_current_line()) }) + vim.api.nvim_win_set_cursor(0, { lines_count, col }) + end + + vim.cmd("startinsert") +end + +function M.reset() + if log_buf and vim.api.nvim_buf_is_valid(log_buf) then + vim.api.nvim_buf_delete(log_buf, { force = true }) + log_buf = nil + end +end + +return M diff --git a/lua/hobs/prompt.lua b/lua/hobs/prompt.lua new file mode 100644 index 0000000..2a1d6a9 --- /dev/null +++ b/lua/hobs/prompt.lua @@ -0,0 +1,39 @@ +local M = {} + +function M.build(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 + +return M
\ No newline at end of file diff --git a/lua/hobs/util.lua b/lua/hobs/util.lua new file mode 100644 index 0000000..5cda17c --- /dev/null +++ b/lua/hobs/util.lua @@ -0,0 +1,101 @@ +local M = {} + +--- +-- Validate that `search_lines` matches exactly once in `buffer_lines`. +-- Returns the start index (1-based) and end index (inclusive) of the match, +-- or errors if there is not exactly one match. +local function find_exact_match(buffer_lines, search_lines) + local search_text = table.concat(search_lines, "\n") + + local matches = {} + local search_len = #search_lines + + for i = 1, #buffer_lines - search_len + 1 do + local candidate = table.concat(vim.list_slice(buffer_lines, i, i + search_len - 1), "\n") + if candidate == search_text then + table.insert(matches, { start = i, end_ = i + search_len - 1 }) + end + end + + if #matches == 0 then + error("search text not found in buffer") + elseif #matches > 1 then + error("search text matches multiple times") + end + + return matches[1].start, matches[1].end_ +end + +--- +-- Insert `lines` before the lines matched by `search_lines`. +-- Returns a new table of lines with the insertion applied. +-- Errors if `search_lines` does not match exactly once. +function M.insert_before(buffer_lines, search_lines, insert_lines) + local start, end_ = find_exact_match(buffer_lines, search_lines) + + local result = {} + + for i = 1, start - 1 do + table.insert(result, buffer_lines[i]) + end + + for _, line in ipairs(insert_lines) do + table.insert(result, line) + end + + for i = start, #buffer_lines do + table.insert(result, buffer_lines[i]) + end + + return result +end + +--- +-- Append `lines` after the lines matched by `search_lines`. +-- Returns a new table of lines with the append applied. +-- Errors if `search_lines` does not match exactly once. +function M.append_after(buffer_lines, search_lines, append_lines) + local start, end_ = find_exact_match(buffer_lines, search_lines) + + local result = {} + + for i = 1, end_ do + table.insert(result, buffer_lines[i]) + end + + for _, line in ipairs(append_lines) do + table.insert(result, line) + end + + for i = end_ + 1, #buffer_lines do + table.insert(result, buffer_lines[i]) + end + + return result +end + +--- +-- Replace the lines matched by `search_lines` with `replace_lines`. +-- Returns a new table of lines with the replacement applied. +-- Errors if `search_lines` does not match exactly once. +function M.replace(buffer_lines, search_lines, replace_lines) + local start, end_ = find_exact_match(buffer_lines, search_lines) + + local result = {} + + for i = 1, start - 1 do + table.insert(result, buffer_lines[i]) + end + + for _, line in ipairs(replace_lines) do + table.insert(result, line) + end + + for i = end_ + 1, #buffer_lines do + table.insert(result, buffer_lines[i]) + end + + return result +end + +return M |