From fd8df2abd5464a9aac874081a95e805cb95b207d Mon Sep 17 00:00:00 2001 From: Dominik Polakovics Date: Fri, 31 Jan 2025 09:49:15 +0100 Subject: [PATCH] initial change to tools logic --- .chatgpt_config.yaml | 19 +- lua/chatgpt_nvim/config.lua | 21 +- lua/chatgpt_nvim/init.lua | 652 ++++++++------------- lua/chatgpt_nvim/tools/edit_file.lua | 27 + lua/chatgpt_nvim/tools/execute_command.lua | 17 + lua/chatgpt_nvim/tools/init.lua | 39 ++ lua/chatgpt_nvim/tools/manager.lua | 65 ++ lua/chatgpt_nvim/tools/read_file.lua | 17 + lua/chatgpt_nvim/tools/replace_in_file.lua | 47 ++ 9 files changed, 484 insertions(+), 420 deletions(-) create mode 100644 lua/chatgpt_nvim/tools/edit_file.lua create mode 100644 lua/chatgpt_nvim/tools/execute_command.lua create mode 100644 lua/chatgpt_nvim/tools/init.lua create mode 100644 lua/chatgpt_nvim/tools/manager.lua create mode 100644 lua/chatgpt_nvim/tools/read_file.lua create mode 100644 lua/chatgpt_nvim/tools/replace_in_file.lua diff --git a/.chatgpt_config.yaml b/.chatgpt_config.yaml index c34dc1a..3d14460 100644 --- a/.chatgpt_config.yaml +++ b/.chatgpt_config.yaml @@ -1,17 +1,26 @@ project_name: "chatgpt_nvim" default_prompt_blocks: - "basic-prompt" - - "workflow-prompt" -directories: - - "." + - "secure-coding" initial_files: - "README.md" + debug: false +improved_debug: false + preview_changes: false interactive_file_selection: false partial_acceptance: false -improved_debug: false + enable_debug_commands: true -token_limit: 128000 +prompt_char_limit: 300000 enable_chunking: false enable_step_by_step: true + +# New tool auto-accept config +tool_auto_accept: + readFile: false + editFile: false + executeCommand: false + # If you set any of these to true, it will auto accept them without prompting. + # 'executeCommand' should remain false by default unless you're certain it's safe. diff --git a/lua/chatgpt_nvim/config.lua b/lua/chatgpt_nvim/config.lua index e239044..1465b6a 100644 --- a/lua/chatgpt_nvim/config.lua +++ b/lua/chatgpt_nvim/config.lua @@ -40,7 +40,6 @@ function M.load() initial_prompt = "", directories = { "." }, default_prompt_blocks = {}, - -- Changed default from 128000 to 16384 as requested prompt_char_limit = 300000, project_name = "", debug = false, @@ -50,9 +49,15 @@ function M.load() partial_acceptance = false, improved_debug = false, enable_chunking = false, - -- New default for step-by-step enable_step_by_step = true, - enable_debug_commands = false + enable_debug_commands = false, + + -- New table for tool auto-accept configuration + tool_auto_accept = { + readFile = false, + editFile = false, + executeCommand = false, + } } if fd then @@ -98,13 +103,21 @@ function M.load() if type(result.enable_chunking) == "boolean" then config.enable_chunking = result.enable_chunking end - -- Added logic to load enable_step_by_step from user config if type(result.enable_step_by_step) == "boolean" then config.enable_step_by_step = result.enable_step_by_step end if type(result.enable_debug_commands) == "boolean" then config.enable_debug_commands = result.enable_debug_commands end + + -- Load tool_auto_accept if present + if type(result.tool_auto_accept) == "table" then + for k, v in pairs(result.tool_auto_accept) do + if config.tool_auto_accept[k] ~= nil and type(v) == "boolean" then + config.tool_auto_accept[k] = v + end + end + end end end else diff --git a/lua/chatgpt_nvim/init.lua b/lua/chatgpt_nvim/init.lua index 8a7ab88..3e51f7e 100644 --- a/lua/chatgpt_nvim/init.lua +++ b/lua/chatgpt_nvim/init.lua @@ -6,8 +6,14 @@ local config = require('chatgpt_nvim.config') local ui = require('chatgpt_nvim.ui') local prompts = require('chatgpt_nvim.prompts') +local tools_manager = require("chatgpt_nvim.tools.manager") +local tools_module = require("chatgpt_nvim.tools") + local ok_yaml, lyaml = pcall(require, "lyaml") +------------------------------------------------------------------------------ +-- UTILITIES +------------------------------------------------------------------------------ local function copy_to_clipboard(text) vim.fn.setreg('+', text) end @@ -48,19 +54,6 @@ local function read_file(path) return data end -local function is_directory(path) - local stat = vim.loop.fs_stat(path) - return stat and stat.type == "directory" -end - -local function handle_step_by_step_if_needed(prompt, conf) - local length = #prompt - if not conf.enable_step_by_step or length <= (conf.prompt_char_limit or 8000) then - return { prompt } - end - return { prompts["step-prompt"] } -end - local function close_existing_buffer_by_name(pattern) for _, b in ipairs(vim.api.nvim_list_bufs()) do local name = vim.api.nvim_buf_get_name(b) @@ -70,265 +63,99 @@ local function close_existing_buffer_by_name(pattern) end end -local function preview_changes(changes, conf) - close_existing_buffer_by_name("ChatGPT_Changes_Preview$") - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(bufnr, "ChatGPT_Changes_Preview") - vim.api.nvim_buf_set_option(bufnr, "filetype", "diff") - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { - "# Preview of Changes:", - "# (Close this window to apply changes or use :q to cancel)", - "" - }) - for _, fileinfo in ipairs(changes) do - local indicator = (fileinfo.delete == true) and "Delete file" or "Write file" - vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { - string.format("=== %s: %s ===", indicator, fileinfo.path or "") - }) - if fileinfo.content then - local lines = vim.split(fileinfo.content, "\n") - for _, line in ipairs(lines) do - vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { line }) - end - end - vim.api.nvim_buf_set_lines(bufnr, -1, -1, false, { "" }) +------------------------------------------------------------------------------ +-- PROMPT CONSTRUCTION +------------------------------------------------------------------------------ +local function build_tools_available_block() + -- We'll list each tool from tools_module.available_tools + local lines = {} + lines[#lines+1] = "" + for _, t in ipairs(tools_module.available_tools) do + lines[#lines+1] = string.format("- **%s**: %s\n Usage: %s", + t.name, t.explanation, t.usage + ) end - - vim.cmd("vsplit") - vim.cmd("buffer " .. bufnr) + lines[#lines+1] = "" + return table.concat(lines, "\n") end -local function partial_accept(changes, conf) - close_existing_buffer_by_name("ChatGPT_Partial_Accept$") - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(bufnr, "ChatGPT_Partial_Accept") - vim.api.nvim_buf_set_option(bufnr, "filetype", "diff") +local function build_prompt(user_input, dirs, conf) + local root = vim.fn.getcwd() + local initial_files = conf.initial_files or {} + local final_sections = {} - local lines = { - "# Remove or comment out (prepend '#') any changes you do NOT want, then :wq, :x, or :bd to finalize partial acceptance", - "" - } - for _, fileinfo in ipairs(changes) do - local action = (fileinfo.delete == true) and "[DELETE]" or "[WRITE]" - table.insert(lines, string.format("%s %s", action, fileinfo.path or "")) - if fileinfo.content then - local content_lines = vim.split(fileinfo.content, "\n") - for _, cl in ipairs(content_lines) do - table.insert(lines, " " .. cl) - end - end - table.insert(lines, "") + -- 1) + if conf.initial_prompt and conf.initial_prompt ~= "" then + table.insert(final_sections, "\n" .. conf.initial_prompt .. "\n\n") end - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + -- 2) + table.insert(final_sections, build_tools_available_block()) - local final_changes = {} - local function on_write() - local edited_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local keep_current = false - local current_fileinfo = { path = nil, content = nil, delete = false } - local content_accum = {} + -- 3) + local task_lines = {} + table.insert(task_lines, "") + task_lines[#task_lines+1] = user_input + for _, file_path in ipairs(initial_files) do + task_lines[#task_lines+1] = ("'%s' (see below for file content)"):format(file_path) + end + task_lines[#task_lines+1] = "\n" + table.insert(final_sections, table.concat(task_lines, "\n")) - for _, line in ipairs(edited_lines) do - if line:match("^#") or line == "" then - goto continue + -- 4) + local file_content_blocks = {} + for _, file_path in ipairs(initial_files) do + local full_path = root .. "/" .. file_path + if is_subpath(root, full_path) then + local fdata = read_file(full_path) + if fdata then + file_content_blocks[#file_content_blocks+1] = string.format( + "\n%s\n", file_path, fdata + ) end - local del_match = line:match("^%[DELETE%] (.+)") - local write_match = line:match("^%[WRITE%] (.+)") - if del_match then - if keep_current and (current_fileinfo.path ~= nil) then - if #content_accum > 0 then - current_fileinfo.content = table.concat(content_accum, "\n") - end - table.insert(final_changes, current_fileinfo) - end - keep_current = true - current_fileinfo = { path = del_match, delete = true, content = nil } - content_accum = {} - elseif write_match then - if keep_current and (current_fileinfo.path ~= nil) then - if #content_accum > 0 then - current_fileinfo.content = table.concat(content_accum, "\n") - end - table.insert(final_changes, current_fileinfo) - end - keep_current = true - current_fileinfo = { path = write_match, delete = false, content = nil } - content_accum = {} - else - if keep_current then - table.insert(content_accum, line:gsub("^%s*", "")) - end - end - ::continue:: end - - if keep_current and (current_fileinfo.path ~= nil) then - if #content_accum > 0 then - current_fileinfo.content = table.concat(content_accum, "\n") - end - table.insert(final_changes, current_fileinfo) - end - - vim.api.nvim_buf_set_option(bufnr, "modified", false) + end + if #file_content_blocks > 0 then + table.insert(final_sections, table.concat(file_content_blocks, "\n\n")) end - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = bufnr, - once = true, - callback = function() - on_write() - vim.cmd("bd! " .. bufnr) - end - }) + -- 5) + local env_lines = {} + env_lines[#env_lines+1] = "" + env_lines[#env_lines+1] = "# VSCode Visible Files" + for _, f in ipairs(initial_files) do + env_lines[#env_lines+1] = f + end + env_lines[#env_lines+1] = "" + env_lines[#env_lines+1] = "# VSCode Open Tabs" + env_lines[#env_lines+1] = "..." + env_lines[#env_lines+1] = "" + env_lines[#env_lines+1] = "# Current Time" + env_lines[#env_lines+1] = os.date("%x, %X (%Z)") + env_lines[#env_lines+1] = "" + env_lines[#env_lines+1] = "# Current Working Directory (" .. root .. ") Files" + env_lines[#env_lines+1] = context.get_project_structure(dirs, conf) or "" + env_lines[#env_lines+1] = "" + env_lines[#env_lines+1] = "# Current Mode" + env_lines[#env_lines+1] = "ACT MODE" + env_lines[#env_lines+1] = "" + table.insert(final_sections, table.concat(env_lines, "\n")) - vim.cmd("split") - vim.cmd("buffer " .. bufnr) - - vim.wait(60000, function() - local winids = vim.api.nvim_tabpage_list_wins(0) - for _, w in ipairs(winids) do - local b = vim.api.nvim_win_get_buf(w) - if b == bufnr then - return false - end - end - return true - end) - - return final_changes + return table.concat(final_sections, "\n\n") end -local function store_prompt_for_reference(prompt, conf) - close_existing_buffer_by_name("ChatGPT_Generated_Prompt$") - local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(bufnr, "ChatGPT_Generated_Prompt") - vim.api.nvim_buf_set_option(bufnr, "filetype", "markdown") - - local lines = { - "# Below is the generated prompt. You can keep it for reference:", - "" - } - local prompt_lines = vim.split(prompt, "\n") - for _, line in ipairs(prompt_lines) do - table.insert(lines, line) - end - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - vim.cmd("vsplit") - vim.cmd("buffer " .. bufnr) -end - --- Updated to allow flags/args for 'ls' and 'grep' -local function execute_debug_command(cmd, conf) - if type(cmd) ~= "table" or not cmd.command then - return "Invalid command object." - end - - local command = cmd.command - if command == "ls" then - -- Accept optional flags/args - local dir = cmd.dir or "." - local args = cmd.args or {} - local cmd_str = "ls" - if #args > 0 then - cmd_str = cmd_str .. " " .. table.concat(args, " ") - end - cmd_str = cmd_str .. " " .. dir - - local handle = io.popen(cmd_str) - if not handle then - return "Failed to run ls command." - end - local result = handle:read("*a") or "" - handle:close() - return "Listing files in: " .. dir .. "\n" .. result - - elseif command == "grep" then - -- Accept optional flags/args - -- If the user wants to do something like: - -- args: ["-r", "somePattern", "someDir"] - -- we just pass them all to grep. - local args = cmd.args or {} - if #args == 0 then - -- fallback to old usage - local pattern = cmd.pattern - local target = cmd.target - if not pattern or not target then - return "Usage for grep: {command='grep', args=['-r','pattern','target']} or {pattern='', target=''}" - end - local stat = vim.loop.fs_stat(target) - if not stat then - return "Cannot grep: target path does not exist" - end - -- old logic remains for backward compatibility - if stat.type == "directory" then - local handle = io.popen("ls -p " .. target .. " | grep -v /") - if not handle then - return "Failed to read directory contents for grep." - end - local all_files = {} - for file in handle:read("*a"):gmatch("[^\n]+") do - table.insert(all_files, target .. "/" .. file) - end - handle:close() - local results = {} - local function grep_in_file(search_string, filepath) - local content = read_file(filepath) - if not content then - return "Could not read file: " .. filepath - end - local lines = {} - local line_num = 0 - for line in content:gmatch("([^\n]*)\n?") do - line_num = line_num + 1 - if line:find(search_string, 1, true) then - table.insert(lines, filepath .. ":" .. line_num .. ":" .. line) - end - end - return (#lines == 0) and ("No matches in " .. filepath) or table.concat(lines, "\n") - end - for _, f in ipairs(all_files) do - local fstat = vim.loop.fs_stat(f) - if fstat and fstat.type == "file" then - table.insert(results, grep_in_file(pattern, f)) - end - end - return table.concat(results, "\n") - else - local function grep_in_file(search_string, filepath) - local content = read_file(filepath) - if not content then - return "Could not read file: " .. filepath - end - local lines = {} - local line_num = 0 - for line in content:gmatch("([^\n]*)\n?") do - line_num = line_num + 1 - if line:find(search_string, 1, true) then - table.insert(lines, filepath .. ":" .. line_num .. ":" .. line) - end - end - return (#lines == 0) and ("No matches in " .. filepath) or table.concat(lines, "\n") - end - return grep_in_file(pattern, target) - end - else - -- new approach with flags/args - local cmd_str = "grep " .. table.concat(args, " ") - local handle = io.popen(cmd_str) - if not handle then - return "Failed to run grep command." - end - local result = handle:read("*a") or "" - handle:close() - return result - end - else - return "Unknown command: " .. command +local function handle_step_by_step_if_needed(prompt, conf) + local length = #prompt + local limit = conf.prompt_char_limit or 8000 + if (not conf.enable_step_by_step) or (length <= limit) then + return { prompt } end + return { prompts["step-prompt"] } end +------------------------------------------------------------------------------ +-- :ChatGPT Command +------------------------------------------------------------------------------ local function run_chatgpt_command() local conf = config.load() ui.setup_ui(conf) @@ -350,7 +177,7 @@ local function run_chatgpt_command() vim.api.nvim_buf_set_option(bufnr, "modifiable", true) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { - "# Enter your prompt below.", + "# Enter your main user prompt (task) below.", "", "Save & close with :wq, :x, or :bd to finalize your prompt." }) @@ -360,57 +187,25 @@ local function run_chatgpt_command() callback = function() local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local user_input = table.concat(lines, "\n") - if user_input == "" or user_input:find("^# Enter your prompt below.") then + if user_input == "" or user_input:find("^# Enter your main user prompt %(task%) below.") then vim.api.nvim_out_write("No valid input provided.\n") vim.api.nvim_buf_set_option(bufnr, "modified", false) return end - local project_structure = context.get_project_structure(dirs, conf) - local initial_files = conf.initial_files or {} - local included_sections = {} + local final_prompt = build_prompt(user_input, dirs, conf) + local chunks = handle_step_by_step_if_needed(final_prompt, conf) - for _, item in ipairs(initial_files) do - local root = vim.fn.getcwd() - local full_path = root .. "/" .. item - if is_directory(full_path) then - local dir_files = context.get_project_files({item}, conf) - for _, f in ipairs(dir_files) do - local path = root .. "/" .. f - local data = read_file(path) - if data then - table.insert(included_sections, "\nFile: `" .. f .. "`\n```\n" .. data .. "\n```\n") - end - end - else - local data = read_file(full_path) - if data then - table.insert(included_sections, "\nFile: `" .. item .. "`\n```\n" .. data .. "\n```\n") - end - end - end + close_existing_buffer_by_name("ChatGPT_Generated_Prompt$") + local bufnr_ref = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr_ref, "ChatGPT_Generated_Prompt") + vim.api.nvim_buf_set_option(bufnr_ref, "filetype", "markdown") - local initial_sections = { - "## Basic Prompt Instructions:\n", - conf.initial_prompt .. "\n\n\n", - "## User Instructions:\n", - user_input .. "\n\n\n", - "## Context/Data:\n", - "Project name: " .. (conf.project_name or "") .. "\n", - "Project Structure:\n", - project_structure, - table.concat(included_sections, "\n") - } + local prompt_lines = vim.split(chunks[1], "\n") + vim.api.nvim_buf_set_lines(bufnr_ref, 0, -1, false, prompt_lines) + vim.cmd("vsplit") + vim.cmd("buffer " .. bufnr_ref) - if conf.enable_debug_commands then - table.insert(initial_sections, "\n## Debug Commands Info:\n") - table.insert(initial_sections, prompts["debug-commands-info"]) - end - - local prompt = table.concat(initial_sections, "\n") - store_prompt_for_reference(prompt, conf) - - local chunks = handle_step_by_step_if_needed(prompt, conf) copy_to_clipboard(chunks[1]) if #chunks == 1 then vim.api.nvim_out_write("Prompt copied to clipboard! Paste into ChatGPT.\n") @@ -425,6 +220,9 @@ local function run_chatgpt_command() vim.cmd("buffer " .. bufnr) end +------------------------------------------------------------------------------ +-- :ChatGPTPaste Command +------------------------------------------------------------------------------ local function run_chatgpt_paste_command() local conf = config.load() ui.setup_ui(conf) @@ -441,10 +239,11 @@ local function run_chatgpt_paste_command() return end + -- 1) Debug commands if data.commands and conf.enable_debug_commands then local results = {} for _, cmd in ipairs(data.commands) do - table.insert(results, execute_debug_command(cmd, conf)) + table.insert(results, require('chatgpt_nvim').execute_debug_command(cmd, conf)) end local output = table.concat(results, "\n\n") copy_to_clipboard(output) @@ -452,8 +251,18 @@ local function run_chatgpt_paste_command() return end + -- 2) Tools (handle multiple calls in one request) + if data.tools then + local output_messages = tools_manager.handle_tool_calls(data.tools, conf, is_subpath, read_file) + -- If the output is too large (over conf.prompt_char_limit?), we might respond with a special message. + copy_to_clipboard(output_messages) + print("Tool call results have been processed and copied to clipboard.") + return + end + + -- 3) If we see project_name & files => final changes or file requests + -- (If the user is still using older YAML style, we handle it, but not recommended.) if data.project_name and data.files then - ui.debug_log("Received project_name and files in response.") if data.project_name ~= conf.project_name then vim.api.nvim_err_writeln("Project name mismatch. Aborting.") return @@ -469,7 +278,7 @@ local function run_chatgpt_paste_command() if is_final then if conf.preview_changes then - preview_changes(data.files, conf) + require('chatgpt_nvim.init').preview_changes(data.files, conf) print("Close the preview window to apply changes, or use :q to cancel.") local closed = vim.wait(60000, function() local bufs = vim.api.nvim_list_bufs() @@ -485,11 +294,12 @@ local function run_chatgpt_paste_command() vim.api.nvim_err_writeln("Preview not closed in time. Aborting.") return end + end local final_files = data.files if conf.partial_acceptance then - final_files = partial_accept(data.files, conf) + final_files = require('chatgpt_nvim.init').partial_accept(data.files, conf) if #final_files == 0 then vim.api.nvim_err_writeln("No changes remain after partial acceptance. Aborting.") return @@ -500,30 +310,28 @@ local function run_chatgpt_paste_command() for _, fileinfo in ipairs(final_files) do if not fileinfo.path then vim.api.nvim_err_writeln("Invalid file entry. Must have 'path'.") - goto continue - end - - if not is_subpath(root, fileinfo.path) then - vim.api.nvim_err_writeln("Invalid path outside project root: " .. fileinfo.path) - goto continue - end - - if fileinfo.delete == true then - ui.debug_log("Deleting file: " .. fileinfo.path) - handler.delete_file(fileinfo.path, conf) - print("Deleted: " .. fileinfo.path) - elseif fileinfo.content then - ui.debug_log("Writing file: " .. fileinfo.path) - handler.write_file(fileinfo.path, fileinfo.content, conf) - print("Wrote: " .. fileinfo.path) else - vim.api.nvim_err_writeln("Invalid file entry. Must have 'content' or 'delete'.") + if not is_subpath(root, fileinfo.path) then + vim.api.nvim_err_writeln("Invalid path outside project root: " .. fileinfo.path) + else + if fileinfo.delete == true then + ui.debug_log("Deleting file: " .. fileinfo.path) + handler.delete_file(fileinfo.path, conf) + print("Deleted: " .. fileinfo.path) + elseif fileinfo.content then + ui.debug_log("Writing file: " .. fileinfo.path) + handler.write_file(fileinfo.path, fileinfo.content, conf) + print("Wrote: " .. fileinfo.path) + else + vim.api.nvim_err_writeln("Invalid file entry. Must have 'content' or 'delete'.") + end + end end - ::continue:: end - else + -- Not final => user is requesting more files local requested_paths = {} + local root = vim.fn.getcwd() for _, fileinfo in ipairs(data.files) do if fileinfo.path then table.insert(requested_paths, fileinfo.path) @@ -531,14 +339,13 @@ local function run_chatgpt_paste_command() end local file_sections = {} - local root = vim.fn.getcwd() for _, f in ipairs(requested_paths) do local path = root .. "/" .. f local content = read_file(path) if content then - table.insert(file_sections, "\nFile: `" .. f .. "`\n```\n" .. content .. "\n```\n") + table.insert(file_sections, ("\nFile: `%s`\n```\n%s\n```\n"):format(f, content)) else - table.insert(file_sections, "\nFile: `" .. f .. "`\n```\n(Could not read file)\n```\n") + table.insert(file_sections, ("\nFile: `%s`\n```\n(Could not read file)\n```\n"):format(f)) end end @@ -547,7 +354,7 @@ local function run_chatgpt_paste_command() "\n\nProject name: " .. (conf.project_name or ""), "\n\nBelow are the requested files from the project, each preceded by its filename in backticks and enclosed in triple backticks.\n", table.concat(file_sections, "\n"), - "\n\nIf you need more files, please respond again in YAML listing additional files. If you have all information you need, provide the final YAML with `project_name` and `files` (with `content` or `delete`) to apply changes.\n" + "\n\nIf you need more files, please respond again in YAML listing additional files. Or use the new 'tools' array approach to read/edit files. If you have all info, provide final changes or just proceed." } local prompt = table.concat(sections, "\n") @@ -565,17 +372,21 @@ local function run_chatgpt_paste_command() end copy_to_clipboard(prompt) - print("Prompt (with requested files) copied to clipboard! Paste it into the ChatGPT O1 model.") + print("Prompt (with requested files) copied to clipboard! Paste it into ChatGPT.") end else - vim.api.nvim_err_writeln("Invalid response. Expected 'project_name' and 'files'.") + vim.api.nvim_err_writeln("Invalid response. Expected either 'tools' or 'project_name & files' in YAML.") end end +------------------------------------------------------------------------------ +-- :ChatGPTCurrentBuffer +------------------------------------------------------------------------------ local function run_chatgpt_current_buffer_command() local conf = config.load() ui.setup_ui(conf) ui.debug_log("Running :ChatGPTCurrentBuffer command.") + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) local user_input = table.concat(lines, "\n") local dirs = conf.directories or {"."} @@ -586,92 +397,19 @@ local function run_chatgpt_current_buffer_command() end end - local project_structure = context.get_project_structure(dirs, conf) - local initial_files = conf.initial_files or {} - local included_sections = {} + local final_prompt = build_prompt(user_input, dirs, conf) + local chunks = handle_step_by_step_if_needed(final_prompt, conf) - local function is_directory(path) - local stat = vim.loop.fs_stat(path) - return stat and stat.type == "directory" - end + close_existing_buffer_by_name("ChatGPT_Generated_Prompt$") + local bufnr_ref = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr_ref, "ChatGPT_Generated_Prompt") + vim.api.nvim_buf_set_option(bufnr_ref, "filetype", "markdown") - local function read_file(path) - local fd = vim.loop.fs_open(path, "r", 438) - if not fd then - return nil - end - local stat = vim.loop.fs_fstat(fd) - if not stat then - vim.loop.fs_close(fd) - return nil - end - local data = vim.loop.fs_read(fd, stat.size, 0) - vim.loop.fs_close(fd) - return data - end + local prompt_lines = vim.split(chunks[1], "\n") + vim.api.nvim_buf_set_lines(bufnr_ref, 0, -1, false, prompt_lines) + vim.cmd("vsplit") + vim.cmd("buffer " .. bufnr_ref) - for _, item in ipairs(initial_files) do - local root = vim.fn.getcwd() - local full_path = root .. "/" .. item - if is_directory(full_path) then - local dir_files = context.get_project_files({item}, conf) - for _, f in ipairs(dir_files) do - local path = root .. "/" .. f - local data = read_file(path) - if data then - table.insert(included_sections, "\nFile: `" .. f .. "`\n```\n" .. data .. "\n```\n") - end - end - else - local data = read_file(full_path) - if data then - table.insert(included_sections, "\nFile: `" .. item .. "`\n```\n" .. data .. "\n```\n") - end - end - end - - local initial_sections = { - "## Basic Prompt Instructions:\n", - conf.initial_prompt .. "\n\n\n", - "## User Instructions:\n", - user_input .. "\n\n\n", - "## Context/Data:\n", - "Project name: " .. (conf.project_name or "") .. "\n", - "Project Structure:\n", - project_structure, - table.concat(included_sections, "\n") - } - - if conf.enable_debug_commands then - table.insert(initial_sections, "\n## Debug Commands Info:\n") - table.insert(initial_sections, prompts["debug-commands-info"]) - end - - local prompt = table.concat(initial_sections, "\n") - - local function store_prompt_for_reference(pr) - close_existing_buffer_by_name("ChatGPT_Generated_Prompt$") - local bufnr_ref = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_name(bufnr_ref, "ChatGPT_Generated_Prompt") - vim.api.nvim_buf_set_option(bufnr_ref, "filetype", "markdown") - - local lines_ref = { - "# Below is the generated prompt. You can keep it for reference:", - "" - } - local pr_lines = vim.split(pr, "\n") - for _, line in ipairs(pr_lines) do - table.insert(lines_ref, line) - end - - vim.api.nvim_buf_set_lines(bufnr_ref, 0, -1, false, lines_ref) - vim.cmd("vsplit") - vim.cmd("buffer " .. bufnr_ref) - end - - store_prompt_for_reference(prompt, conf) - - local chunks = handle_step_by_step_if_needed(prompt, conf) copy_to_clipboard(chunks[1]) if #chunks == 1 then vim.api.nvim_out_write("Prompt (from current buffer) copied to clipboard! Paste into ChatGPT.\n") @@ -680,9 +418,101 @@ local function run_chatgpt_current_buffer_command() end end +------------------------------------------------------------------------------ +-- PUBLIC API +------------------------------------------------------------------------------ M.run_chatgpt_command = run_chatgpt_command M.run_chatgpt_paste_command = run_chatgpt_paste_command M.run_chatgpt_current_buffer_command = run_chatgpt_current_buffer_command -M.execute_debug_command = execute_debug_command + +-- Provide debug commands for "ls" and "grep" +M.execute_debug_command = function(cmd, conf) + if type(cmd) ~= "table" or not cmd.command then + return "Invalid command object." + end + local command = cmd.command + if command == "ls" then + local dir = cmd.dir or "." + local args = cmd.args or {} + local cmd_str = "ls" + if #args > 0 then + cmd_str = cmd_str .. " " .. table.concat(args, " ") + end + cmd_str = cmd_str .. " " .. dir + + local handle = io.popen(cmd_str) + if not handle then + return "Failed to run ls command." + end + local result = handle:read("*a") or "" + handle:close() + return "Listing files in: " .. dir .. "\n" .. result + + elseif command == "grep" then + local args = cmd.args or {} + if #args == 0 then + local pattern = cmd.pattern + local target = cmd.target + if not pattern or not target then + return "Usage for grep: {command='grep', args=['-r','pattern','target']} or {pattern='', target=''}" + end + local stat = vim.loop.fs_stat(target) + if not stat then + return "Cannot grep: target path does not exist" + end + + local function do_grep(search_string, filepath) + local c = read_file(filepath) + if not c then + return "Could not read file: " .. filepath + end + local lines = {} + local line_num = 0 + for line in c:gmatch("([^\n]*)\n?") do + line_num = line_num + 1 + if line:find(search_string, 1, true) then + lines[#lines+1] = filepath .. ":" .. line_num .. ":" .. line + end + end + return (#lines == 0) and ("No matches in " .. filepath) or table.concat(lines, "\n") + end + + if stat.type == "directory" then + local h = io.popen("ls -p " .. target .. " | grep -v /") + if not h then + return "Failed to read directory contents for grep." + end + local all_files = {} + for file in h:read("*a"):gmatch("[^\n]+") do + all_files[#all_files+1] = target .. "/" .. file + end + h:close() + local results = {} + for _, f in ipairs(all_files) do + local fstat = vim.loop.fs_stat(f) + if fstat and fstat.type == "file" then + results[#results+1] = do_grep(pattern, f) + end + end + return table.concat(results, "\n") + else + return do_grep(pattern, target) + end + + else + -- new approach with flags/args + local cmd_str = "grep " .. table.concat(args, " ") + local handle = io.popen(cmd_str) + if not handle then + return "Failed to run grep command." + end + local result = handle:read("*a") or "" + handle:close() + return result + end + else + return "Unknown command: " .. command + end +end return M diff --git a/lua/chatgpt_nvim/tools/edit_file.lua b/lua/chatgpt_nvim/tools/edit_file.lua new file mode 100644 index 0000000..c126a59 --- /dev/null +++ b/lua/chatgpt_nvim/tools/edit_file.lua @@ -0,0 +1,27 @@ +local handler = require("chatgpt_nvim.handler") +local M = {} + +M.run = function(tool_call, conf, prompt_user_tool_accept, is_subpath, read_file) + local path = tool_call.path + local new_content = tool_call.content + + if not path or not new_content then + return "[edit_file] Missing 'path' or 'content'." + end + local root = vim.fn.getcwd() + + if not is_subpath(root, path) then + return string.format("Tool [edit_file for '%s'] REJECTED. Path outside project root.", path) + end + + handler.write_file(path, new_content, conf) + local msg = {} + msg[#msg+1] = string.format("Tool [edit_file for '%s'] Result:\nThe content was successfully saved to %s.", path, path) + msg[#msg+1] = "\nHere is the full, updated content of the file that was saved:\n" + msg[#msg+1] = string.format("\n%s\n", path, new_content) + msg[#msg+1] = "\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.\n" + + return table.concat(msg, "") +end + +return M diff --git a/lua/chatgpt_nvim/tools/execute_command.lua b/lua/chatgpt_nvim/tools/execute_command.lua new file mode 100644 index 0000000..5fb9274 --- /dev/null +++ b/lua/chatgpt_nvim/tools/execute_command.lua @@ -0,0 +1,17 @@ +local M = {} + +M.run = function(tool_call, conf, prompt_user_tool_accept, is_subpath, read_file) + local cmd = tool_call.command + if not cmd then + return "[executeCommand] Missing 'command'." + end + local handle = io.popen(cmd) + if not handle then + return string.format("Tool [executeCommand '%s'] FAILED to popen.", cmd) + end + local result = handle:read("*a") or "" + handle:close() + return string.format("Tool [executeCommand '%s'] Result:\n%s", cmd, result) +end + +return M diff --git a/lua/chatgpt_nvim/tools/init.lua b/lua/chatgpt_nvim/tools/init.lua new file mode 100644 index 0000000..48383dd --- /dev/null +++ b/lua/chatgpt_nvim/tools/init.lua @@ -0,0 +1,39 @@ +local read_file_tool = require("chatgpt_nvim.tools.read_file") +local edit_file_tool = require("chatgpt_nvim.tools.edit_file") +local replace_in_file_tool = require("chatgpt_nvim.tools.replace_in_file") +local execute_command_tool = require("chatgpt_nvim.tools.execute_command") + +local M = {} + +-- We can store a table of available tools here +M.available_tools = { + { + name = "readFile", + usage = "Retrieve the contents of a file. Provide { tool='readFile', path='...' }", + explanation = "Use this to read file content directly from the disk." + }, + { + name = "editFile", + usage = "Overwrite an entire file's content. Provide { tool='editFile', path='...', content='...' }", + explanation = "Use this when you want to replace a file with new content." + }, + { + name = "replace_in_file", + usage = "Perform a search-and-replace. Provide { tool='replace_in_file', path='...', replacements=[ { search='...', replace='...' }, ... ] }", + explanation = "Use this to apply incremental changes without fully overwriting the file." + }, + { + name = "executeCommand", + usage = "Run a shell command. Provide { tool='executeCommand', command='...' }", + explanation = "Use with caution, especially for destructive operations (rm, sudo, etc.)." + }, +} + +M.tools_by_name = { + readFile = read_file_tool, + editFile = edit_file_tool, + replace_in_file = replace_in_file_tool, + executeCommand = execute_command_tool +} + +return M diff --git a/lua/chatgpt_nvim/tools/manager.lua b/lua/chatgpt_nvim/tools/manager.lua new file mode 100644 index 0000000..49c1674 --- /dev/null +++ b/lua/chatgpt_nvim/tools/manager.lua @@ -0,0 +1,65 @@ +local tools_module = require("chatgpt_nvim.tools") +local uv = vim.loop +local M = {} + +-- Simple destructive command check +local function is_destructive_command(cmd) + if not cmd then return false end + local destructive_list = { "rm", "sudo", "mv", "cp" } + for _, keyword in ipairs(destructive_list) do + if cmd:match("(^" .. keyword .. "[%s$])") or cmd:match("[%s]" .. keyword .. "[%s$]") then + return true + end + end + return false +end + +-- Prompt user if not auto-accepted or if command is destructive +local function prompt_user_tool_accept(tool_call, conf) + local function ask_user(msg) + vim.api.nvim_out_write(msg .. " [y/N]: ") + local ans = vim.fn.input("") + if ans:lower() == "y" then + return true + end + return false + end + + local auto_accept = conf.tool_auto_accept[tool_call.tool] + if tool_call.tool == "executeCommand" and auto_accept then + if is_destructive_command(tool_call.command) then + auto_accept = false + end + end + + if not auto_accept then + return ask_user(("Tool request: %s -> Accept?"):format(tool_call.tool or "unknown")) + else + return true + end +end + +-- We'll pass references to `read_file` from init. +local function handle_tool_calls(tools, conf, is_subpath_fn, read_file_fn) + local messages = {} + for _, call in ipairs(tools) do + -- Prompt user acceptance + local accepted = prompt_user_tool_accept(call, conf) + if not accepted then + table.insert(messages, string.format("Tool [%s] was rejected by user.", call.tool or "nil")) + else + local tool_impl = tools_module.tools_by_name[call.tool] + if tool_impl then + local msg = tool_impl.run(call, conf, prompt_user_tool_accept, is_subpath_fn, read_file_fn) + table.insert(messages, msg) + else + table.insert(messages, string.format("Unknown tool type: '%s'", call.tool or "nil")) + end + end + end + + return table.concat(messages, "\n\n") +end + +M.handle_tool_calls = handle_tool_calls +return M diff --git a/lua/chatgpt_nvim/tools/read_file.lua b/lua/chatgpt_nvim/tools/read_file.lua new file mode 100644 index 0000000..5519b20 --- /dev/null +++ b/lua/chatgpt_nvim/tools/read_file.lua @@ -0,0 +1,17 @@ +local uv = vim.loop +local M = {} + +M.run = function(tool_call, conf, prompt_user_tool_accept, is_subpath, read_file) + local path = tool_call.path + if not path then + return "[read_file] Missing 'path'." + end + local file_data = read_file(path) + if file_data then + return string.format("Tool [read_file for '%s'] Result:\n\n%s", path, file_data) + else + return string.format("Tool [read_file for '%s'] FAILED. File not found or not readable.", path) + end +end + +return M diff --git a/lua/chatgpt_nvim/tools/replace_in_file.lua b/lua/chatgpt_nvim/tools/replace_in_file.lua new file mode 100644 index 0000000..4059a5e --- /dev/null +++ b/lua/chatgpt_nvim/tools/replace_in_file.lua @@ -0,0 +1,47 @@ +local handler = require("chatgpt_nvim.handler") + +local M = {} + +local function search_and_replace(original, replacements) + -- Basic approach: do a global plain text replace for each entry + local updated = original + for _, r in ipairs(replacements) do + local search_str = r.search or "" + local replace_str = r.replace or "" + -- Here we do a global plain text replacement + updated = updated:gsub(search_str, replace_str) + end + return updated +end + +M.run = function(tool_call, conf, prompt_user_tool_accept, is_subpath, read_file) + local path = tool_call.path + local replacements = tool_call.replacements or {} + + if not path or #replacements == 0 then + return "[replace_in_file] Missing 'path' or 'replacements'." + end + local root = vim.fn.getcwd() + + if not is_subpath(root, path) then + return string.format("Tool [replace_in_file for '%s'] REJECTED. Path outside project root.", path) + end + + local orig_data = read_file(path) + if not orig_data then + return string.format("[replace_in_file for '%s'] FAILED. Could not read file.", path) + end + + local updated_data = search_and_replace(orig_data, replacements) + handler.write_file(path, updated_data, conf) + + local msg = {} + msg[#msg+1] = string.format("[replace_in_file for '%s'] Result:\nThe content was successfully saved to %s.", path, path) + msg[#msg+1] = "\nHere is the full, updated content of the file that was saved:\n" + msg[#msg+1] = string.format("\n%s\n", path, updated_data) + msg[#msg+1] = "\nIMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference.\n" + + return table.concat(msg, "") +end + +return M