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