diff --git a/lua/autocommands.lua b/lua/autocommands.lua index 22ba7c4..28d1d07 100644 --- a/lua/autocommands.lua +++ b/lua/autocommands.lua @@ -65,9 +65,11 @@ vim.api.nvim_create_autocmd({ "InsertLeave", "TextChanged", "TextChangedI", "Buf if auto_save_debounce[event.buf] ~= 1 then auto_save_debounce[event.buf] = 1 vim.defer_fn(function() - vim.api.nvim_buf_call(event.buf, function() - vim.api.nvim_command("silent! write") - end) + if vim.api.nvim_buf_is_valid(event.buf) then + vim.api.nvim_buf_call(event.buf, function() + vim.api.nvim_command("silent! write") + end) + end auto_save_debounce[event.buf] = nil end, 500) end diff --git a/lua/plugins/llm_vim_command.lua b/lua/plugins/llm_vim_command.lua new file mode 100644 index 0000000..4dcd4ba --- /dev/null +++ b/lua/plugins/llm_vim_command.lua @@ -0,0 +1,330 @@ +return { + "MunifTanjim/nui.nvim", + keys = { + { ",v", desc = "LLM Vim Command" }, + }, + config = function() + local Input = require("nui.input") + local Popup = require("nui.popup") + + local history_file = vim.fn.stdpath("state") .. "/llm_vim_command_history.json" + local history = {} + local history_index = 0 + + local function load_history() + local f = io.open(history_file, "r") + if f then + local content = f:read("*a") + f:close() + local ok, decoded = pcall(vim.fn.json_decode, content) + if ok and type(decoded) == "table" then + history = decoded + end + end + end + + local function save_history() + local f = io.open(history_file, "w") + if f then + f:write(vim.fn.json_encode(history)) + f:close() + end + end + + local function add_to_history(prompt) + if prompt == "" then + return + end + if #history > 0 and history[#history] == prompt then + return + end + table.insert(history, prompt) + if #history > 100 then + table.remove(history, 1) + end + save_history() + end + + load_history() + + local function call_llm(prompt, callback) + local system_prompt = [[You are a vim command generator. Given a task description, output ONLY a vim command or sequence of keystrokes that accomplishes the task. +Rules: +- Output ONLY the vim command/keystrokes, nothing else +- No explanations, no markdown, no code blocks +- For ex commands, start with : +- For normal mode sequences, just output the keys +- If the task mentions working on a selection, assume visual mode is active +- Keep commands as simple and idiomatic as possible]] + + local body = vim.fn.json_encode({ + model = "azure-gpt-5.1-2025-11-13", + messages = { + { role = "system", content = system_prompt }, + { role = "user", content = prompt }, + }, + temperature = 0.1, + }) + + local stdout_data = {} + local stderr_data = {} + + vim.fn.jobstart({ + "curl", + "-s", + "-X", "POST", + "http://h001.net.joshuabell.xyz:8094/v1/chat/completions", + "-H", "Content-Type: application/json", + "-d", body, + }, { + stdout_buffered = true, + stderr_buffered = true, + on_stdout = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stdout_data, line) + end + end + end + end, + on_stderr = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(stderr_data, line) + end + end + end + end, + on_exit = vim.schedule_wrap(function(_, exit_code) + if exit_code ~= 0 then + local err_msg = table.concat(stderr_data, "\n") + if err_msg == "" then + err_msg = "exit code " .. exit_code + end + callback(nil, "curl failed: " .. err_msg) + return + end + if #stdout_data == 0 then + callback(nil, "curl returned empty response. stderr: " .. table.concat(stderr_data, "\n")) + return + end + local response_body = table.concat(stdout_data, "") + local ok, decoded = pcall(vim.fn.json_decode, response_body) + if not ok or not decoded.choices or not decoded.choices[1] then + callback(nil, "Failed to parse LLM response: " .. response_body) + return + end + local command = decoded.choices[1].message.content + command = command:gsub("^%s+", ""):gsub("%s+$", "") + callback(command, nil) + end), + }) + end + + local function execute_command(command) + if command:sub(1, 1) == ":" then + local cmd = command:sub(2) + vim.fn.histadd("cmd", cmd) + local ok, err = pcall(vim.cmd, cmd) + if not ok then + vim.notify("Command failed: " .. err, vim.log.levels.ERROR) + end + else + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes(command, true, false, true), + "n", + false + ) + end + end + + local function show_edit_dialog(command) + local input = Input({ + position = "50%", + size = { width = 60 }, + border = { + style = "rounded", + text = { top = " Edit Command ", top_align = "center" }, + }, + }, { + prompt = "> ", + default_value = command, + on_submit = function(value) + if value and value ~= "" then + execute_command(value) + end + end, + }) + + input:mount() + input:map("n", "", function() + input:unmount() + end, { noremap = true }) + end + + local function show_confirmation(command) + local lines = vim.split(command, "\n") + local max_line_len = 0 + for _, line in ipairs(lines) do + max_line_len = math.max(max_line_len, #line) + end + local width = math.max(40, math.min(80, max_line_len + 4)) + local height = #lines + 4 + + local popup = Popup({ + position = "50%", + size = { width = width, height = height }, + border = { + style = "rounded", + text = { top = " LLM Vim Command ", top_align = "center" }, + }, + enter = true, + focusable = true, + buf_options = { + modifiable = true, + readonly = false, + }, + }) + + popup:mount() + + local content = {} + for _, line in ipairs(lines) do + table.insert(content, line) + end + table.insert(content, "") + table.insert(content, string.rep("─", width - 2)) + table.insert(content, "[y] run [e] edit [q/Esc] cancel") + + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, content) + vim.api.nvim_buf_set_option(popup.bufnr, "modifiable", false) + vim.cmd("stopinsert") + + local function close() + popup:unmount() + end + + popup:map("n", "q", close, { noremap = true }) + popup:map("n", "", close, { noremap = true }) + + popup:map("n", "y", function() + close() + execute_command(command) + end, { noremap = true }) + + popup:map("n", "e", function() + close() + show_edit_dialog(command) + end, { noremap = true }) + end + + local function show_prompt_dialog(is_visual) + local selection = nil + if is_visual then + vim.cmd('normal! "vy') + selection = vim.fn.getreg("v") + end + + history_index = #history + 1 + + local popup = Popup({ + position = "50%", + size = { width = 60, height = 5 }, + border = { + style = "rounded", + text = { top = " Describe vim task ", top_align = "center" }, + }, + enter = true, + focusable = true, + win_options = { + wrap = true, + linebreak = true, + }, + }) + + popup:mount() + vim.api.nvim_buf_set_option(popup.bufnr, "modifiable", true) + vim.api.nvim_buf_set_option(popup.bufnr, "buftype", "nofile") + vim.cmd("startinsert") + + local function get_text() + local lines = vim.api.nvim_buf_get_lines(popup.bufnr, 0, -1, false) + return table.concat(lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "") + end + + local function set_text(text) + local lines = vim.split(text, "\n") + vim.api.nvim_buf_set_lines(popup.bufnr, 0, -1, false, lines) + local last_line = lines[#lines] or "" + vim.api.nvim_win_set_cursor(popup.winid, { #lines, #last_line }) + end + + local function close_popup() + popup:unmount() + end + + local function submit() + local task = get_text() + close_popup() + + if task == "" then + return + end + + add_to_history(task) + + local full_prompt = task + if selection and selection ~= "" then + full_prompt = full_prompt .. "\n\nThe current selection is:\n" .. selection + end + + vim.notify("Generating vim command...", vim.log.levels.INFO) + + call_llm(full_prompt, function(command, err) + if err then + vim.notify(err, vim.log.levels.ERROR) + return + end + show_confirmation(command) + end) + end + + popup:map("n", "", close_popup, { noremap = true }) + popup:map("i", "", close_popup, { noremap = true }) + popup:map("i", "", submit, { noremap = true }) + popup:map("n", "", submit, { noremap = true }) + + local function history_prev() + if #history == 0 then + return + end + history_index = math.max(1, history_index - 1) + set_text(history[history_index]) + end + + local function history_next() + if #history == 0 then + return + end + history_index = math.min(#history + 1, history_index + 1) + if history_index > #history then + set_text("") + else + set_text(history[history_index]) + end + end + + popup:map("i", "", history_prev, { noremap = true }) + popup:map("i", "", history_next, { noremap = true }) + popup:map("i", "", history_prev, { noremap = true }) + popup:map("i", "", history_next, { noremap = true }) + end + + vim.keymap.set({ "n", "v" }, ",v", function() + local mode = vim.fn.mode() + show_prompt_dialog(mode == "v" or mode == "V" or mode == "\22") + end, { desc = "LLM Vim Command" }) + end, +}