change-to-tools #2

Merged
dominik.polakovics merged 5 commits from change-to-tools into main 2025-01-31 13:38:30 +01:00
9 changed files with 484 additions and 420 deletions
Showing only changes of commit fd8df2abd5 - Show all commits

View File

@@ -1,17 +1,26 @@
project_name: "chatgpt_nvim" project_name: "chatgpt_nvim"
default_prompt_blocks: default_prompt_blocks:
- "basic-prompt" - "basic-prompt"
- "workflow-prompt" - "secure-coding"
directories:
- "."
initial_files: initial_files:
- "README.md" - "README.md"
debug: false debug: false
improved_debug: false
preview_changes: false preview_changes: false
interactive_file_selection: false interactive_file_selection: false
partial_acceptance: false partial_acceptance: false
improved_debug: false
enable_debug_commands: true enable_debug_commands: true
token_limit: 128000 prompt_char_limit: 300000
enable_chunking: false enable_chunking: false
enable_step_by_step: true 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.

View File

@@ -40,7 +40,6 @@ function M.load()
initial_prompt = "", initial_prompt = "",
directories = { "." }, directories = { "." },
default_prompt_blocks = {}, default_prompt_blocks = {},
-- Changed default from 128000 to 16384 as requested
prompt_char_limit = 300000, prompt_char_limit = 300000,
project_name = "", project_name = "",
debug = false, debug = false,
@@ -50,9 +49,15 @@ function M.load()
partial_acceptance = false, partial_acceptance = false,
improved_debug = false, improved_debug = false,
enable_chunking = false, enable_chunking = false,
-- New default for step-by-step
enable_step_by_step = true, 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 if fd then
@@ -98,13 +103,21 @@ function M.load()
if type(result.enable_chunking) == "boolean" then if type(result.enable_chunking) == "boolean" then
config.enable_chunking = result.enable_chunking config.enable_chunking = result.enable_chunking
end end
-- Added logic to load enable_step_by_step from user config
if type(result.enable_step_by_step) == "boolean" then if type(result.enable_step_by_step) == "boolean" then
config.enable_step_by_step = result.enable_step_by_step config.enable_step_by_step = result.enable_step_by_step
end end
if type(result.enable_debug_commands) == "boolean" then if type(result.enable_debug_commands) == "boolean" then
config.enable_debug_commands = result.enable_debug_commands config.enable_debug_commands = result.enable_debug_commands
end 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
end end
else else

View File

@@ -6,8 +6,14 @@ local config = require('chatgpt_nvim.config')
local ui = require('chatgpt_nvim.ui') local ui = require('chatgpt_nvim.ui')
local prompts = require('chatgpt_nvim.prompts') 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") local ok_yaml, lyaml = pcall(require, "lyaml")
------------------------------------------------------------------------------
-- UTILITIES
------------------------------------------------------------------------------
local function copy_to_clipboard(text) local function copy_to_clipboard(text)
vim.fn.setreg('+', text) vim.fn.setreg('+', text)
end end
@@ -48,19 +54,6 @@ local function read_file(path)
return data return data
end 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) local function close_existing_buffer_by_name(pattern)
for _, b in ipairs(vim.api.nvim_list_bufs()) do for _, b in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(b) local name = vim.api.nvim_buf_get_name(b)
@@ -70,265 +63,99 @@ local function close_existing_buffer_by_name(pattern)
end end
end end
local function preview_changes(changes, conf) ------------------------------------------------------------------------------
close_existing_buffer_by_name("ChatGPT_Changes_Preview$") -- PROMPT CONSTRUCTION
local bufnr = vim.api.nvim_create_buf(false, true) ------------------------------------------------------------------------------
vim.api.nvim_buf_set_name(bufnr, "ChatGPT_Changes_Preview") local function build_tools_available_block()
vim.api.nvim_buf_set_option(bufnr, "filetype", "diff") -- We'll list each tool from tools_module.available_tools
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 "<no path>")
})
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, { "" })
end
vim.cmd("vsplit")
vim.cmd("buffer " .. bufnr)
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 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 "<no path>"))
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, "")
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
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 = {}
for _, line in ipairs(edited_lines) do
if line:match("^#") or line == "" then
goto continue
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
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = bufnr,
once = true,
callback = function()
on_write()
vim.cmd("bd! " .. bufnr)
end
})
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
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='<text>', target='<path>'}"
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 lines = {}
local line_num = 0 lines[#lines+1] = "<tools_available>"
for line in content:gmatch("([^\n]*)\n?") do for _, t in ipairs(tools_module.available_tools) do
line_num = line_num + 1 lines[#lines+1] = string.format("- **%s**: %s\n Usage: %s",
if line:find(search_string, 1, true) then t.name, t.explanation, t.usage
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
end end
lines[#lines+1] = "</tools_available>"
return table.concat(lines, "\n")
end end
local function build_prompt(user_input, dirs, conf)
local root = vim.fn.getcwd()
local initial_files = conf.initial_files or {}
local final_sections = {}
-- 1) <initial_prompts>
if conf.initial_prompt and conf.initial_prompt ~= "" then
table.insert(final_sections, "<initial_prompts>\n" .. conf.initial_prompt .. "\n</initial_prompts>\n")
end
-- 2) <tools_available>
table.insert(final_sections, build_tools_available_block())
-- 3) <task>
local task_lines = {}
table.insert(task_lines, "<task>")
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] = "</task>\n"
table.insert(final_sections, table.concat(task_lines, "\n"))
-- 4) <file_content path="...">
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(
"<file_content path=\"%s\">\n%s\n</file_content>", file_path, fdata
)
end
end
end
if #file_content_blocks > 0 then
table.insert(final_sections, table.concat(file_content_blocks, "\n\n"))
end
-- 5) <environment_details>
local env_lines = {}
env_lines[#env_lines+1] = "<environment_details>"
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] = "</environment_details>"
table.insert(final_sections, table.concat(env_lines, "\n"))
return table.concat(final_sections, "\n\n")
end
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 function run_chatgpt_command()
local conf = config.load() local conf = config.load()
ui.setup_ui(conf) 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_option(bufnr, "modifiable", true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 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." "Save & close with :wq, :x, or :bd to finalize your prompt."
}) })
@@ -360,57 +187,25 @@ local function run_chatgpt_command()
callback = function() callback = function()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local user_input = table.concat(lines, "\n") 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_out_write("No valid input provided.\n")
vim.api.nvim_buf_set_option(bufnr, "modified", false) vim.api.nvim_buf_set_option(bufnr, "modified", false)
return return
end end
local project_structure = context.get_project_structure(dirs, conf) local final_prompt = build_prompt(user_input, dirs, conf)
local initial_files = conf.initial_files or {} local chunks = handle_step_by_step_if_needed(final_prompt, conf)
local included_sections = {}
for _, item in ipairs(initial_files) do close_existing_buffer_by_name("ChatGPT_Generated_Prompt$")
local root = vim.fn.getcwd() local bufnr_ref = vim.api.nvim_create_buf(false, true)
local full_path = root .. "/" .. item vim.api.nvim_buf_set_name(bufnr_ref, "ChatGPT_Generated_Prompt")
if is_directory(full_path) then vim.api.nvim_buf_set_option(bufnr_ref, "filetype", "markdown")
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 = { local prompt_lines = vim.split(chunks[1], "\n")
"## Basic Prompt Instructions:\n", vim.api.nvim_buf_set_lines(bufnr_ref, 0, -1, false, prompt_lines)
conf.initial_prompt .. "\n\n\n", vim.cmd("vsplit")
"## User Instructions:\n", vim.cmd("buffer " .. bufnr_ref)
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")
store_prompt_for_reference(prompt, conf)
local chunks = handle_step_by_step_if_needed(prompt, conf)
copy_to_clipboard(chunks[1]) copy_to_clipboard(chunks[1])
if #chunks == 1 then if #chunks == 1 then
vim.api.nvim_out_write("Prompt copied to clipboard! Paste into ChatGPT.\n") 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) vim.cmd("buffer " .. bufnr)
end end
------------------------------------------------------------------------------
-- :ChatGPTPaste Command
------------------------------------------------------------------------------
local function run_chatgpt_paste_command() local function run_chatgpt_paste_command()
local conf = config.load() local conf = config.load()
ui.setup_ui(conf) ui.setup_ui(conf)
@@ -441,10 +239,11 @@ local function run_chatgpt_paste_command()
return return
end end
-- 1) Debug commands
if data.commands and conf.enable_debug_commands then if data.commands and conf.enable_debug_commands then
local results = {} local results = {}
for _, cmd in ipairs(data.commands) do 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 end
local output = table.concat(results, "\n\n") local output = table.concat(results, "\n\n")
copy_to_clipboard(output) copy_to_clipboard(output)
@@ -452,8 +251,18 @@ local function run_chatgpt_paste_command()
return return
end 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 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 if data.project_name ~= conf.project_name then
vim.api.nvim_err_writeln("Project name mismatch. Aborting.") vim.api.nvim_err_writeln("Project name mismatch. Aborting.")
return return
@@ -469,7 +278,7 @@ local function run_chatgpt_paste_command()
if is_final then if is_final then
if conf.preview_changes 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.") print("Close the preview window to apply changes, or use :q to cancel.")
local closed = vim.wait(60000, function() local closed = vim.wait(60000, function()
local bufs = vim.api.nvim_list_bufs() 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.") vim.api.nvim_err_writeln("Preview not closed in time. Aborting.")
return return
end end
end end
local final_files = data.files local final_files = data.files
if conf.partial_acceptance then 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 if #final_files == 0 then
vim.api.nvim_err_writeln("No changes remain after partial acceptance. Aborting.") vim.api.nvim_err_writeln("No changes remain after partial acceptance. Aborting.")
return return
@@ -500,14 +310,10 @@ local function run_chatgpt_paste_command()
for _, fileinfo in ipairs(final_files) do for _, fileinfo in ipairs(final_files) do
if not fileinfo.path then if not fileinfo.path then
vim.api.nvim_err_writeln("Invalid file entry. Must have 'path'.") vim.api.nvim_err_writeln("Invalid file entry. Must have 'path'.")
goto continue else
end
if not is_subpath(root, fileinfo.path) then if not is_subpath(root, fileinfo.path) then
vim.api.nvim_err_writeln("Invalid path outside project root: " .. fileinfo.path) vim.api.nvim_err_writeln("Invalid path outside project root: " .. fileinfo.path)
goto continue else
end
if fileinfo.delete == true then if fileinfo.delete == true then
ui.debug_log("Deleting file: " .. fileinfo.path) ui.debug_log("Deleting file: " .. fileinfo.path)
handler.delete_file(fileinfo.path, conf) handler.delete_file(fileinfo.path, conf)
@@ -519,11 +325,13 @@ local function run_chatgpt_paste_command()
else else
vim.api.nvim_err_writeln("Invalid file entry. Must have 'content' or 'delete'.") vim.api.nvim_err_writeln("Invalid file entry. Must have 'content' or 'delete'.")
end end
::continue::
end end
end
end
else else
-- Not final => user is requesting more files
local requested_paths = {} local requested_paths = {}
local root = vim.fn.getcwd()
for _, fileinfo in ipairs(data.files) do for _, fileinfo in ipairs(data.files) do
if fileinfo.path then if fileinfo.path then
table.insert(requested_paths, fileinfo.path) table.insert(requested_paths, fileinfo.path)
@@ -531,14 +339,13 @@ local function run_chatgpt_paste_command()
end end
local file_sections = {} local file_sections = {}
local root = vim.fn.getcwd()
for _, f in ipairs(requested_paths) do for _, f in ipairs(requested_paths) do
local path = root .. "/" .. f local path = root .. "/" .. f
local content = read_file(path) local content = read_file(path)
if content then 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 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
end end
@@ -547,7 +354,7 @@ local function run_chatgpt_paste_command()
"\n\nProject name: " .. (conf.project_name or ""), "\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", "\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"), 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") local prompt = table.concat(sections, "\n")
@@ -565,17 +372,21 @@ local function run_chatgpt_paste_command()
end end
copy_to_clipboard(prompt) 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 end
else 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
end end
------------------------------------------------------------------------------
-- :ChatGPTCurrentBuffer
------------------------------------------------------------------------------
local function run_chatgpt_current_buffer_command() local function run_chatgpt_current_buffer_command()
local conf = config.load() local conf = config.load()
ui.setup_ui(conf) ui.setup_ui(conf)
ui.debug_log("Running :ChatGPTCurrentBuffer command.") ui.debug_log("Running :ChatGPTCurrentBuffer command.")
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local user_input = table.concat(lines, "\n") local user_input = table.concat(lines, "\n")
local dirs = conf.directories or {"."} local dirs = conf.directories or {"."}
@@ -586,92 +397,19 @@ local function run_chatgpt_current_buffer_command()
end end
end end
local project_structure = context.get_project_structure(dirs, conf) local final_prompt = build_prompt(user_input, dirs, conf)
local initial_files = conf.initial_files or {} local chunks = handle_step_by_step_if_needed(final_prompt, conf)
local included_sections = {}
local function is_directory(path)
local stat = vim.loop.fs_stat(path)
return stat and stat.type == "directory"
end
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
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$") close_existing_buffer_by_name("ChatGPT_Generated_Prompt$")
local bufnr_ref = vim.api.nvim_create_buf(false, true) 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_name(bufnr_ref, "ChatGPT_Generated_Prompt")
vim.api.nvim_buf_set_option(bufnr_ref, "filetype", "markdown") vim.api.nvim_buf_set_option(bufnr_ref, "filetype", "markdown")
local lines_ref = { local prompt_lines = vim.split(chunks[1], "\n")
"# Below is the generated prompt. You can keep it for reference:", vim.api.nvim_buf_set_lines(bufnr_ref, 0, -1, false, prompt_lines)
""
}
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("vsplit")
vim.cmd("buffer " .. bufnr_ref) 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]) copy_to_clipboard(chunks[1])
if #chunks == 1 then if #chunks == 1 then
vim.api.nvim_out_write("Prompt (from current buffer) copied to clipboard! Paste into ChatGPT.\n") 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
end end
------------------------------------------------------------------------------
-- PUBLIC API
------------------------------------------------------------------------------
M.run_chatgpt_command = run_chatgpt_command M.run_chatgpt_command = run_chatgpt_command
M.run_chatgpt_paste_command = run_chatgpt_paste_command M.run_chatgpt_paste_command = run_chatgpt_paste_command
M.run_chatgpt_current_buffer_command = run_chatgpt_current_buffer_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='<text>', target='<path>'}"
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 return M

View File

@@ -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("<final_file_content path=\"%s\">\n%s\n</final_file_content>", 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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("<final_file_content path=\"%s\">\n%s\n</final_file_content>", 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