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
------------------------------------------------------------------------------
-- PROMPT CONSTRUCTION
------------------------------------------------------------------------------
local function build_tools_available_block()
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
lines[#lines+1] = ""
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)
if conf.initial_prompt and conf.initial_prompt ~= "" then
table.insert(final_sections, "\n" .. conf.initial_prompt .. "\n\n")
end
-- 2)
table.insert(final_sections, build_tools_available_block())
-- 3)
local task_lines = {}
task_lines[#task_lines+1] = ""
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"))
-- 4) from initial_files
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
end
end
if #file_content_blocks > 0 then
table.insert(final_sections, table.concat(file_content_blocks, "\n\n"))
end
-- 4.1) Dynamic file inclusion via @ operator in user_input
local dynamic_files = {}
for file in user_input:gmatch("@([^%s]+)") do
local already_included = false
for _, existing in ipairs(initial_files) do
if existing == file then
already_included = true
break
end
end
if not already_included then
table.insert(dynamic_files, file)
end
end
local dynamic_file_blocks = {}
for _, file in ipairs(dynamic_files) do
local full_path = root .. "/" .. file
if is_subpath(root, full_path) then
local fdata = read_file(full_path)
if fdata then
dynamic_file_blocks[#dynamic_file_blocks+1] = string.format(
"\n%s\n", file, fdata
)
end
end
end
if #dynamic_file_blocks > 0 then
table.insert(final_sections, table.concat(dynamic_file_blocks, "\n\n"))
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_prompt(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"))
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
------------------------------------------------------------------------------
local function run_chatgpt_command()
package.loaded["chatgpt_nvim.config"] = nil
local config = require("chatgpt_nvim.config")
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")
-- Set omnifunc for file name auto-completion
vim.api.nvim_buf_set_option(bufnr, "omnifunc", "v:lua.chatgpt_file_complete")
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.",
"",
"You can include files by typing @filename in your prompt.",
"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
------------------------------------------------------------------------------
local function run_chatgpt_paste_command()
package.loaded["chatgpt_nvim.config"] = nil
local config = require("chatgpt_nvim.config")
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
-- Check if we have tools
if data.tools then
-- Must also verify project name
if not data.project_name or data.project_name ~= conf.project_name then
vim.api.nvim_err_writeln("Project name mismatch or missing. Aborting tool usage.")
return
end
local output_messages = tools_manager.handle_tool_calls(data.tools, conf, is_subpath, read_file)
copy_to_clipboard(output_messages)
print("Tool call results have been processed and copied to clipboard.")
return
end
-- If we see project_name & files => older YAML style. We handle it but it's discouraged now.
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 'tools:' approach. If you have all info, provide final changes or continue your instructions."
}
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("No tools or recognized file instructions found. Provide 'tools:' or older 'project_name & files'.")
end
end
------------------------------------------------------------------------------
-- :ChatGPTCurrentBuffer
------------------------------------------------------------------------------
local function run_chatgpt_current_buffer_command()
package.loaded["chatgpt_nvim.config"] = nil
local config = require("chatgpt_nvim.config")
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
-- New: Global function for file name auto-completion in ChatGPT prompt
function _G.chatgpt_file_complete(findstart, base)
if findstart == 1 then
local line = vim.fn.getline('.')
local col = vim.fn.col('.')
local start = line:sub(1, col):find("@[^%s]*$")
if start then
return start - 1
else
return -1
end
else
local conf = config.load()
local files = context.get_project_files({'.'}, conf)
local completions = {}
local esc_base = base:gsub("([^%w])", "%%%1")
for _, f in ipairs(files) do
if f:match("^" .. esc_base) then
table.insert(completions, f)
end
end
return completions
end
end
return M