aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJosh Rahm <rahm@josher.dev>2026-08-09 21:46:47 -0600
committerJosh Rahm <rahm@josher.dev>2026-08-09 21:46:47 -0600
commitd2eadb42ffde7f07b4c2d46efc3724d565ec8255 (patch)
tree47f363814f0df083b826c8ef475ccb12859a982a
downloadhobs.nvim-d2eadb42ffde7f07b4c2d46efc3724d565ec8255.tar.gz
hobs.nvim-d2eadb42ffde7f07b4c2d46efc3724d565ec8255.tar.bz2
hobs.nvim-d2eadb42ffde7f07b4c2d46efc3724d565ec8255.zip
Initial commit.
-rw-r--r--README.md24
-rw-r--r--lua/hobs.lua577
2 files changed, 601 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b52c781
--- /dev/null
+++ b/README.md
@@ -0,0 +1,24 @@
+# Hobs.nvim
+
+In English and in broader Northern European folklore a hob (short for hobgoblin)
+is a type of small household spirit. Hobs are often portrayed as helpful,
+domestic beings which quietly assist with chores around the house in exchange
+for small offerings or simply out of habit.
+
+In this project, the name is used metaphorically: small, invisible workers that
+live inside your editor, quietly helping with tasks while you continue your own
+work.
+
+## Little AI Helpers Which Work Along Side You In Neovim
+
+Most coding agents today follow a serial workflow. Typically something like:
+
+1. Human requests the AI do something
+1. The AI takes control of the repository
+1. Human waits for the AI to finish
+1. AI finishes and returns control of the repository.
+1. Human continues with the work
+
+`hobs.nvim` makes this flow concurrent. Now, the human can request an AI agent
+(A "hob"). The hob can do some simple task in the background, updating the live
+buffer as it goes along all while the human continues with his work.
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
+