519 lines
18 KiB
Lua
519 lines
18 KiB
Lua
local M = {}
|
|
|
|
local context = require('chatgpt_nvim.context')
|
|
local handler = require('chatgpt_nvim.handler')
|
|
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
|
|
|
|
local function parse_response(raw, conf)
|
|
if not ok_yaml then
|
|
vim.api.nvim_err_writeln("lyaml not available. Install with `luarocks install lyaml`.")
|
|
return nil
|
|
end
|
|
local ok, data = pcall(lyaml.load, raw)
|
|
if not ok or not data then
|
|
vim.api.nvim_err_writeln("Failed to parse YAML response.")
|
|
ui.debug_log("RAW response that failed parsing:\n" .. raw)
|
|
return nil
|
|
end
|
|
ui.debug_log("Successfully parsed YAML response.")
|
|
return data
|
|
end
|
|
|
|
local function is_subpath(root, path)
|
|
local root_abs = vim.fn.fnamemodify(root, ":p")
|
|
local target_abs = vim.fn.fnamemodify(path, ":p")
|
|
return target_abs:sub(1, #root_abs) == root_abs
|
|
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
|
|
|
|
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)
|
|
if name:match(pattern) then
|
|
vim.api.nvim_buf_delete(b, { force = true })
|
|
end
|
|
end
|
|
end
|
|
|
|
------------------------------------------------------------------------------
|
|
-- PROMPT CONSTRUCTION
|
|
------------------------------------------------------------------------------
|
|
local function build_tools_available_block()
|
|
-- We'll list each tool from tools_module.available_tools
|
|
local lines = {}
|
|
lines[#lines+1] = "<tools_available>"
|
|
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
|
|
lines[#lines+1] = "</tools_available>"
|
|
return table.concat(lines, "\n")
|
|
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 conf = config.load()
|
|
ui.setup_ui(conf)
|
|
ui.debug_log("Running :ChatGPT command.")
|
|
local dirs = conf.directories or {"."}
|
|
if conf.interactive_file_selection then
|
|
dirs = ui.pick_directories(dirs, conf)
|
|
if #dirs == 0 then
|
|
dirs = conf.directories
|
|
end
|
|
end
|
|
|
|
close_existing_buffer_by_name("ChatGPT_Prompt.md$")
|
|
local bufnr = vim.api.nvim_create_buf(false, false)
|
|
vim.api.nvim_buf_set_name(bufnr, "ChatGPT_Prompt.md")
|
|
vim.api.nvim_buf_set_option(bufnr, "filetype", "markdown")
|
|
vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe")
|
|
vim.api.nvim_buf_set_option(bufnr, "buftype", "")
|
|
vim.api.nvim_buf_set_option(bufnr, "modifiable", true)
|
|
|
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
|
|
"# Enter your main user prompt (task) below.",
|
|
"",
|
|
"Save & close with :wq, :x, or :bd to finalize your prompt."
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
|
buffer = bufnr,
|
|
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 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 final_prompt = build_prompt(user_input, dirs, conf)
|
|
local chunks = handle_step_by_step_if_needed(final_prompt, conf)
|
|
|
|
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 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)
|
|
|
|
copy_to_clipboard(chunks[1])
|
|
if #chunks == 1 then
|
|
vim.api.nvim_out_write("Prompt copied to clipboard! Paste into ChatGPT.\n")
|
|
else
|
|
vim.api.nvim_out_write("Step-by-step prompt copied to clipboard!\n")
|
|
end
|
|
|
|
vim.api.nvim_buf_set_option(bufnr, "modified", false)
|
|
end
|
|
})
|
|
|
|
vim.cmd("buffer " .. bufnr)
|
|
end
|
|
|
|
------------------------------------------------------------------------------
|
|
-- :ChatGPTPaste Command
|
|
------------------------------------------------------------------------------
|
|
local function run_chatgpt_paste_command()
|
|
local conf = config.load()
|
|
ui.setup_ui(conf)
|
|
ui.debug_log("Running :ChatGPTPaste command.")
|
|
print("Reading ChatGPT YAML response from clipboard...")
|
|
local raw = handler.get_clipboard_content(conf)
|
|
if raw == "" then
|
|
vim.api.nvim_err_writeln("Clipboard is empty. Please copy the YAML response from ChatGPT first.")
|
|
return
|
|
end
|
|
|
|
local data = parse_response(raw, conf)
|
|
if not data then
|
|
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, require('chatgpt_nvim').execute_debug_command(cmd, conf))
|
|
end
|
|
local output = table.concat(results, "\n\n")
|
|
copy_to_clipboard(output)
|
|
print("Debug command results copied to clipboard!")
|
|
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
|
|
if data.project_name ~= conf.project_name then
|
|
vim.api.nvim_err_writeln("Project name mismatch. Aborting.")
|
|
return
|
|
end
|
|
|
|
local is_final = false
|
|
for _, fileinfo in ipairs(data.files) do
|
|
if fileinfo.content or fileinfo.delete == true then
|
|
is_final = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if is_final then
|
|
if conf.preview_changes then
|
|
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()
|
|
for _, b in ipairs(bufs) do
|
|
local name = vim.api.nvim_buf_get_name(b)
|
|
if name:match("ChatGPT_Changes_Preview$") then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end)
|
|
if not closed then
|
|
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 = 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
|
|
end
|
|
end
|
|
|
|
local root = vim.fn.getcwd()
|
|
for _, fileinfo in ipairs(final_files) do
|
|
if not fileinfo.path then
|
|
vim.api.nvim_err_writeln("Invalid file entry. Must have 'path'.")
|
|
else
|
|
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
|
|
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)
|
|
end
|
|
end
|
|
|
|
local file_sections = {}
|
|
for _, f in ipairs(requested_paths) do
|
|
local path = root .. "/" .. f
|
|
local content = read_file(path)
|
|
if content then
|
|
table.insert(file_sections, ("\nFile: `%s`\n```\n%s\n```\n"):format(f, content))
|
|
else
|
|
table.insert(file_sections, ("\nFile: `%s`\n```\n(Could not read file)\n```\n"):format(f))
|
|
end
|
|
end
|
|
|
|
local sections = {
|
|
conf.initial_prompt,
|
|
"\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. 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 length = #prompt
|
|
ui.debug_log("Returning requested files. Character count: " .. length)
|
|
|
|
if length > (conf.prompt_char_limit or 8000) and conf.enable_step_by_step then
|
|
local large_step = prompts["step-prompt"]
|
|
copy_to_clipboard(large_step)
|
|
print("Step-by-step guidance copied to clipboard!")
|
|
return
|
|
elseif length > (conf.prompt_char_limit or 8000) then
|
|
vim.api.nvim_err_writeln("Requested files exceed prompt character limit. No step-by-step support enabled.")
|
|
return
|
|
end
|
|
|
|
copy_to_clipboard(prompt)
|
|
print("Prompt (with requested files) copied to clipboard! Paste it into ChatGPT.")
|
|
end
|
|
else
|
|
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 {"."}
|
|
if conf.interactive_file_selection then
|
|
dirs = ui.pick_directories(dirs, conf)
|
|
if #dirs == 0 then
|
|
dirs = conf.directories
|
|
end
|
|
end
|
|
|
|
local final_prompt = build_prompt(user_input, dirs, conf)
|
|
local chunks = handle_step_by_step_if_needed(final_prompt, conf)
|
|
|
|
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 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)
|
|
|
|
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")
|
|
else
|
|
vim.api.nvim_out_write("Step-by-step prompt (from current buffer) copied to clipboard!\n")
|
|
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
|
|
|
|
-- 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
|