Deleting unnecesary features
This commit is contained in:
@@ -234,239 +234,6 @@ local function cmd_gitignore()
|
||||
gitignore.force_update()
|
||||
end
|
||||
|
||||
--- Open ask panel (with optional visual selection)
|
||||
---@param selection table|nil Visual selection info
|
||||
local function cmd_ask(selection)
|
||||
local ask = require("codetyper.features.ask.engine")
|
||||
ask.open(selection)
|
||||
end
|
||||
|
||||
--- Close ask panel
|
||||
local function cmd_ask_close()
|
||||
local ask = require("codetyper.features.ask.engine")
|
||||
ask.close()
|
||||
end
|
||||
|
||||
--- Toggle ask panel
|
||||
local function cmd_ask_toggle()
|
||||
local ask = require("codetyper.features.ask.engine")
|
||||
ask.toggle()
|
||||
end
|
||||
|
||||
--- Clear ask history
|
||||
local function cmd_ask_clear()
|
||||
local ask = require("codetyper.features.ask.engine")
|
||||
ask.clear_history()
|
||||
end
|
||||
|
||||
--- Open agent panel (with optional visual selection)
|
||||
---@param selection table|nil Visual selection info
|
||||
local function cmd_agent(selection)
|
||||
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
|
||||
agent_ui.open(selection)
|
||||
end
|
||||
|
||||
--- Close agent panel
|
||||
local function cmd_agent_close()
|
||||
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
|
||||
agent_ui.close()
|
||||
end
|
||||
|
||||
--- Toggle agent panel
|
||||
local function cmd_agent_toggle()
|
||||
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
|
||||
agent_ui.toggle()
|
||||
end
|
||||
|
||||
--- Stop running agent
|
||||
local function cmd_agent_stop()
|
||||
local agent = require("codetyper.features.agents")
|
||||
if agent.is_running() then
|
||||
agent.stop()
|
||||
utils.notify("Agent stopped")
|
||||
else
|
||||
utils.notify("No agent running", vim.log.levels.INFO)
|
||||
end
|
||||
end
|
||||
|
||||
--- Run the agentic loop with a task
|
||||
---@param task string The task to accomplish
|
||||
---@param agent_name? string Optional agent name
|
||||
local function cmd_agentic_run(task, agent_name)
|
||||
local agentic = require("codetyper.features.agents.engine")
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
-- Open logs panel
|
||||
logs_panel.open()
|
||||
|
||||
logs.info("Starting agentic task: " .. task:sub(1, 50) .. "...")
|
||||
utils.notify("Running agentic task...", vim.log.levels.INFO)
|
||||
|
||||
-- Get current file for context
|
||||
local current_file = vim.fn.expand("%:p")
|
||||
local files = {}
|
||||
if current_file ~= "" then
|
||||
table.insert(files, current_file)
|
||||
end
|
||||
|
||||
agentic.run({
|
||||
task = task,
|
||||
files = files,
|
||||
agent = agent_name or "coder",
|
||||
on_status = function(status)
|
||||
logs.thinking(status)
|
||||
end,
|
||||
on_tool_start = function(name, args)
|
||||
logs.info("Tool: " .. name)
|
||||
end,
|
||||
on_tool_end = function(name, result, err)
|
||||
if err then
|
||||
logs.error(name .. " failed: " .. err)
|
||||
else
|
||||
logs.debug(name .. " completed")
|
||||
end
|
||||
end,
|
||||
on_file_change = function(path, action)
|
||||
logs.info("File " .. action .. ": " .. path)
|
||||
end,
|
||||
on_message = function(msg)
|
||||
if msg.role == "assistant" and type(msg.content) == "string" and msg.content ~= "" then
|
||||
logs.thinking(msg.content:sub(1, 100) .. "...")
|
||||
end
|
||||
end,
|
||||
on_complete = function(result, err)
|
||||
if err then
|
||||
logs.error("Task failed: " .. err)
|
||||
utils.notify("Agentic task failed: " .. err, vim.log.levels.ERROR)
|
||||
else
|
||||
logs.info("Task completed successfully")
|
||||
utils.notify("Agentic task completed!", vim.log.levels.INFO)
|
||||
if result and result ~= "" then
|
||||
-- Show summary in a float
|
||||
vim.schedule(function()
|
||||
vim.notify("Result:\n" .. result:sub(1, 500), vim.log.levels.INFO)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
--- List available agents
|
||||
local function cmd_agentic_list()
|
||||
local agentic = require("codetyper.features.agents.engine")
|
||||
local agents = agentic.list_agents()
|
||||
|
||||
local lines = {
|
||||
"Available Agents",
|
||||
"================",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, agent in ipairs(agents) do
|
||||
local badge = agent.builtin and "[builtin]" or "[custom]"
|
||||
table.insert(lines, string.format(" %s %s", agent.name, badge))
|
||||
table.insert(lines, string.format(" %s", agent.description))
|
||||
table.insert(lines, "")
|
||||
end
|
||||
|
||||
table.insert(lines, "Use :CoderAgenticRun <task> [agent] to run a task")
|
||||
table.insert(lines, "Use :CoderAgenticInit to create custom agents")
|
||||
|
||||
utils.notify(table.concat(lines, "\n"))
|
||||
end
|
||||
|
||||
--- Initialize .coder/agents/ and .coder/rules/ directories
|
||||
local function cmd_agentic_init()
|
||||
local agentic = require("codetyper.features.agents.engine")
|
||||
agentic.init()
|
||||
|
||||
local agents_dir = vim.fn.getcwd() .. "/.coder/agents"
|
||||
local rules_dir = vim.fn.getcwd() .. "/.coder/rules"
|
||||
|
||||
local lines = {
|
||||
"Initialized Coder directories:",
|
||||
"",
|
||||
" " .. agents_dir,
|
||||
" - example.md (template for custom agents)",
|
||||
"",
|
||||
" " .. rules_dir,
|
||||
" - code-style.md (template for project rules)",
|
||||
"",
|
||||
"Edit these files to customize agent behavior.",
|
||||
"Create new .md files to add more agents/rules.",
|
||||
}
|
||||
|
||||
utils.notify(table.concat(lines, "\n"))
|
||||
end
|
||||
|
||||
--- Show chat type switcher modal (Ask/Agent)
|
||||
local function cmd_type_toggle()
|
||||
local switcher = require("codetyper.chat_switcher")
|
||||
switcher.show()
|
||||
end
|
||||
|
||||
--- Toggle logs panel
|
||||
local function cmd_logs_toggle()
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
logs_panel.toggle()
|
||||
end
|
||||
|
||||
--- Show scheduler status and queue info
|
||||
local function cmd_queue_status()
|
||||
local scheduler = require("codetyper.core.scheduler.scheduler")
|
||||
local queue = require("codetyper.core.events.queue")
|
||||
local parser = require("codetyper.parser")
|
||||
|
||||
local status = scheduler.status()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local filepath = vim.fn.expand("%:p")
|
||||
|
||||
local lines = {
|
||||
"Scheduler Status",
|
||||
"================",
|
||||
"",
|
||||
"Running: " .. (status.running and "yes" or "NO"),
|
||||
"Paused: " .. (status.paused and "yes" or "no"),
|
||||
"Active Workers: " .. status.active_workers,
|
||||
"",
|
||||
"Queue Stats:",
|
||||
" Pending: " .. status.queue_stats.pending,
|
||||
" Processing: " .. status.queue_stats.processing,
|
||||
" Completed: " .. status.queue_stats.completed,
|
||||
" Cancelled: " .. status.queue_stats.cancelled,
|
||||
"",
|
||||
}
|
||||
|
||||
-- Check current buffer for prompts
|
||||
if filepath ~= "" then
|
||||
local prompts = parser.find_prompts_in_buffer(bufnr)
|
||||
table.insert(lines, "Current Buffer: " .. vim.fn.fnamemodify(filepath, ":t"))
|
||||
table.insert(lines, " Prompts found: " .. #prompts)
|
||||
for i, p in ipairs(prompts) do
|
||||
local preview = p.content:sub(1, 30):gsub("\n", " ")
|
||||
table.insert(lines, string.format(" %d. Line %d: %s...", i, p.start_line, preview))
|
||||
end
|
||||
end
|
||||
|
||||
utils.notify(table.concat(lines, "\n"))
|
||||
end
|
||||
|
||||
--- Manually trigger queue processing for current buffer
|
||||
local function cmd_queue_process()
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
|
||||
-- Open logs panel to show progress
|
||||
logs_panel.open()
|
||||
|
||||
-- Check all prompts in current buffer
|
||||
autocmds.check_all_prompts()
|
||||
|
||||
utils.notify("Triggered queue processing for current buffer")
|
||||
end
|
||||
|
||||
--- Switch focus between coder and target windows
|
||||
local function cmd_focus()
|
||||
if not window.is_open() then
|
||||
@@ -484,12 +251,9 @@ end
|
||||
|
||||
--- Transform inline /@ @/ tags in current file
|
||||
--- Works on ANY file, not just .coder.* files
|
||||
--- Uses the same processing logic as automatic mode for consistent results
|
||||
local function cmd_transform()
|
||||
local parser = require("codetyper.parser")
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local filepath = vim.fn.expand("%:p")
|
||||
@@ -507,9 +271,7 @@ local function cmd_transform()
|
||||
return
|
||||
end
|
||||
|
||||
-- Open the logs panel to show generation progress
|
||||
logs_panel.open()
|
||||
logs.info("Transform started: " .. #prompts .. " prompt(s) in " .. vim.fn.fnamemodify(filepath, ":t"))
|
||||
utils.notify("Transforming " .. #prompts .. " prompt(s)...", vim.log.levels.INFO)
|
||||
|
||||
utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO)
|
||||
|
||||
@@ -528,8 +290,6 @@ end
|
||||
local function cmd_transform_range(start_line, end_line)
|
||||
local parser = require("codetyper.parser")
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local filepath = vim.fn.expand("%:p")
|
||||
@@ -555,16 +315,10 @@ local function cmd_transform_range(start_line, end_line)
|
||||
return
|
||||
end
|
||||
|
||||
-- Open the logs panel to show generation progress
|
||||
logs_panel.open()
|
||||
logs.info("Transform selection: " .. #prompts .. " prompt(s)")
|
||||
|
||||
utils.notify("Found " .. #prompts .. " prompt(s) in selection to transform...", vim.log.levels.INFO)
|
||||
utils.notify("Transforming " .. #prompts .. " prompt(s)...", vim.log.levels.INFO)
|
||||
|
||||
-- Process each prompt using the same logic as automatic mode (skip processed check for manual mode)
|
||||
for _, prompt in ipairs(prompts) do
|
||||
local clean_prompt = parser.clean_prompt(prompt.content)
|
||||
logs.info("Processing: " .. clean_prompt:sub(1, 40) .. "...")
|
||||
autocmds.process_single_prompt(bufnr, prompt, filepath, true)
|
||||
end
|
||||
end
|
||||
@@ -703,12 +457,9 @@ local function cmd_forget(pattern)
|
||||
end
|
||||
|
||||
--- Transform a single prompt at cursor position
|
||||
--- Uses the same processing logic as automatic mode for consistent results
|
||||
local function cmd_transform_at_cursor()
|
||||
local parser = require("codetyper.parser")
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local filepath = vim.fn.expand("%:p")
|
||||
@@ -726,11 +477,7 @@ local function cmd_transform_at_cursor()
|
||||
return
|
||||
end
|
||||
|
||||
-- Open the logs panel to show generation progress
|
||||
logs_panel.open()
|
||||
|
||||
local clean_prompt = parser.clean_prompt(prompt.content)
|
||||
logs.info("Transform cursor: " .. clean_prompt:sub(1, 40) .. "...")
|
||||
utils.notify("Transforming: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO)
|
||||
|
||||
-- Use the same processing logic as automatic mode (skip processed check for manual mode)
|
||||
@@ -770,21 +517,8 @@ end
|
||||
---@param was_good boolean Whether the response was good
|
||||
local function cmd_llm_feedback(was_good)
|
||||
local llm = require("codetyper.core.llm")
|
||||
-- Get the last used provider from logs or default
|
||||
local provider = "ollama" -- Default assumption
|
||||
|
||||
-- Try to get actual last provider from logs
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local entries = logs.get(10)
|
||||
for i = #entries, 1, -1 do
|
||||
local entry = entries[i]
|
||||
if entry.message and entry.message:match("^LLM:") then
|
||||
provider = entry.message:match("LLM: (%w+)") or provider
|
||||
break
|
||||
end
|
||||
end
|
||||
end)
|
||||
-- Default to ollama for feedback
|
||||
local provider = "ollama"
|
||||
|
||||
llm.report_feedback(provider, was_good)
|
||||
local feedback_type = was_good and "positive" or "negative"
|
||||
@@ -811,32 +545,10 @@ local function coder_cmd(args)
|
||||
tree = cmd_tree,
|
||||
["tree-view"] = cmd_tree_view,
|
||||
reset = cmd_reset,
|
||||
ask = cmd_ask,
|
||||
["ask-close"] = cmd_ask_close,
|
||||
["ask-toggle"] = cmd_ask_toggle,
|
||||
["ask-clear"] = cmd_ask_clear,
|
||||
gitignore = cmd_gitignore,
|
||||
transform = cmd_transform,
|
||||
["transform-cursor"] = cmd_transform_at_cursor,
|
||||
agent = cmd_agent,
|
||||
["agent-close"] = cmd_agent_close,
|
||||
["agent-toggle"] = cmd_agent_toggle,
|
||||
["agent-stop"] = cmd_agent_stop,
|
||||
["type-toggle"] = cmd_type_toggle,
|
||||
["logs-toggle"] = cmd_logs_toggle,
|
||||
["queue-status"] = cmd_queue_status,
|
||||
["queue-process"] = cmd_queue_process,
|
||||
-- Agentic commands
|
||||
["agentic-run"] = function(args)
|
||||
local task = table.concat(vim.list_slice(args.fargs, 2), " ")
|
||||
if task == "" then
|
||||
utils.notify("Usage: Coder agentic-run <task> [agent]", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
cmd_agentic_run(task)
|
||||
end,
|
||||
["agentic-list"] = cmd_agentic_list,
|
||||
["agentic-init"] = cmd_agentic_init,
|
||||
|
||||
["index-project"] = cmd_index_project,
|
||||
["index-status"] = cmd_index_status,
|
||||
memories = cmd_memories,
|
||||
@@ -940,12 +652,7 @@ function M.setup()
|
||||
return {
|
||||
"open", "close", "toggle", "process", "status", "focus",
|
||||
"tree", "tree-view", "reset", "gitignore",
|
||||
"ask", "ask-close", "ask-toggle", "ask-clear",
|
||||
"transform", "transform-cursor",
|
||||
"agent", "agent-close", "agent-toggle", "agent-stop",
|
||||
"agentic-run", "agentic-list", "agentic-init",
|
||||
"type-toggle", "logs-toggle",
|
||||
"queue-status", "queue-process",
|
||||
"index-project", "index-status", "memories", "forget",
|
||||
"auto-toggle", "auto-set",
|
||||
"llm-stats", "llm-feedback-good", "llm-feedback-bad", "llm-reset-stats",
|
||||
@@ -981,24 +688,6 @@ function M.setup()
|
||||
cmd_tree_view()
|
||||
end, { desc = "View tree.log" })
|
||||
|
||||
-- Ask panel commands
|
||||
vim.api.nvim_create_user_command("CoderAsk", function(opts)
|
||||
local selection = nil
|
||||
-- Check if called from visual mode (range is set)
|
||||
if opts.range > 0 then
|
||||
selection = utils.get_visual_selection()
|
||||
end
|
||||
cmd_ask(selection)
|
||||
end, { range = true, desc = "Open Ask panel (with optional visual selection)" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAskToggle", function()
|
||||
cmd_ask_toggle()
|
||||
end, { desc = "Toggle Ask panel" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAskClear", function()
|
||||
cmd_ask_clear()
|
||||
end, { desc = "Clear Ask history" })
|
||||
|
||||
-- Transform commands (inline /@ @/ tag replacement)
|
||||
vim.api.nvim_create_user_command("CoderTransform", function()
|
||||
cmd_transform()
|
||||
@@ -1014,59 +703,6 @@ function M.setup()
|
||||
cmd_transform_range(start_line, end_line)
|
||||
end, { range = true, desc = "Transform /@ @/ tags in visual selection" })
|
||||
|
||||
-- Agent commands
|
||||
vim.api.nvim_create_user_command("CoderAgent", function(opts)
|
||||
local selection = nil
|
||||
-- Check if called from visual mode (range is set)
|
||||
if opts.range > 0 then
|
||||
selection = utils.get_visual_selection()
|
||||
end
|
||||
cmd_agent(selection)
|
||||
end, { range = true, desc = "Open Agent panel (with optional visual selection)" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAgentToggle", function()
|
||||
cmd_agent_toggle()
|
||||
end, { desc = "Toggle Agent panel" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAgentStop", function()
|
||||
cmd_agent_stop()
|
||||
end, { desc = "Stop running agent" })
|
||||
|
||||
-- Agentic commands (full IDE-like agent functionality)
|
||||
vim.api.nvim_create_user_command("CoderAgenticRun", function(opts)
|
||||
local task = opts.args
|
||||
if task == "" then
|
||||
vim.ui.input({ prompt = "Task: " }, function(input)
|
||||
if input and input ~= "" then
|
||||
cmd_agentic_run(input)
|
||||
end
|
||||
end)
|
||||
else
|
||||
cmd_agentic_run(task)
|
||||
end
|
||||
end, {
|
||||
desc = "Run agentic task (IDE-like multi-file changes)",
|
||||
nargs = "*",
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAgenticList", function()
|
||||
cmd_agentic_list()
|
||||
end, { desc = "List available agents" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAgenticInit", function()
|
||||
cmd_agentic_init()
|
||||
end, { desc = "Initialize .coder/agents/ and .coder/rules/ directories" })
|
||||
|
||||
-- Chat type switcher command
|
||||
vim.api.nvim_create_user_command("CoderType", function()
|
||||
cmd_type_toggle()
|
||||
end, { desc = "Show Ask/Agent mode switcher" })
|
||||
|
||||
-- Logs panel command
|
||||
vim.api.nvim_create_user_command("CoderLogs", function()
|
||||
cmd_logs_toggle()
|
||||
end, { desc = "Toggle logs panel" })
|
||||
|
||||
-- Index command - open coder companion for current file
|
||||
vim.api.nvim_create_user_command("CoderIndex", function()
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
@@ -1093,15 +729,6 @@ function M.setup()
|
||||
nargs = "?",
|
||||
})
|
||||
|
||||
-- Queue commands
|
||||
vim.api.nvim_create_user_command("CoderQueueStatus", function()
|
||||
cmd_queue_status()
|
||||
end, { desc = "Show scheduler and queue status" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderQueueProcess", function()
|
||||
cmd_queue_process()
|
||||
end, { desc = "Manually trigger queue processing" })
|
||||
|
||||
-- Preferences commands
|
||||
vim.api.nvim_create_user_command("CoderAutoToggle", function()
|
||||
local preferences = require("codetyper.config.preferences")
|
||||
@@ -1313,145 +940,6 @@ function M.setup()
|
||||
end,
|
||||
})
|
||||
|
||||
-- Conflict mode commands
|
||||
vim.api.nvim_create_user_command("CoderConflictToggle", function()
|
||||
local patch = require("codetyper.core.diff.patch")
|
||||
local current = patch.is_conflict_mode()
|
||||
patch.configure({ use_conflict_mode = not current })
|
||||
utils.notify("Conflict mode " .. (not current and "enabled" or "disabled"), vim.log.levels.INFO)
|
||||
end, { desc = "Toggle conflict mode for code changes" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictResolveAll", function(opts)
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local keep = opts.args ~= "" and opts.args or "theirs"
|
||||
if not vim.tbl_contains({ "ours", "theirs", "both", "none" }, keep) then
|
||||
utils.notify("Invalid option. Use: ours, theirs, both, or none", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
conflict.resolve_all(bufnr, keep)
|
||||
utils.notify("Resolved all conflicts with: " .. keep, vim.log.levels.INFO)
|
||||
end, {
|
||||
nargs = "?",
|
||||
complete = function() return { "ours", "theirs", "both", "none" } end,
|
||||
desc = "Resolve all conflicts (ours/theirs/both/none)"
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictNext", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
conflict.goto_next(vim.api.nvim_get_current_buf())
|
||||
end, { desc = "Go to next conflict" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictPrev", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
conflict.goto_prev(vim.api.nvim_get_current_buf())
|
||||
end, { desc = "Go to previous conflict" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictStatus", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local patch = require("codetyper.core.diff.patch")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local count = conflict.count_conflicts(bufnr)
|
||||
local mode = patch.is_conflict_mode() and "enabled" or "disabled"
|
||||
utils.notify(string.format("Conflicts in buffer: %d | Conflict mode: %s", count, mode), vim.log.levels.INFO)
|
||||
end, { desc = "Show conflict status" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictMenu", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
-- Ensure conflicts are processed first (sets up highlights and keymaps)
|
||||
conflict.process(bufnr)
|
||||
conflict.show_floating_menu(bufnr)
|
||||
end, { desc = "Show conflict resolution menu" })
|
||||
|
||||
-- Manual commands to accept conflicts
|
||||
vim.api.nvim_create_user_command("CoderConflictAcceptCurrent", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
conflict.process(bufnr) -- Ensure keymaps are set up
|
||||
conflict.accept_ours(bufnr)
|
||||
end, { desc = "Accept current (original) code" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictAcceptIncoming", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
conflict.process(bufnr) -- Ensure keymaps are set up
|
||||
conflict.accept_theirs(bufnr)
|
||||
end, { desc = "Accept incoming (AI) code" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictAcceptBoth", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
conflict.process(bufnr)
|
||||
conflict.accept_both(bufnr)
|
||||
end, { desc = "Accept both versions" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictAcceptNone", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
conflict.process(bufnr)
|
||||
conflict.accept_none(bufnr)
|
||||
end, { desc = "Delete conflict (accept none)" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderConflictAutoMenu", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local conf = conflict.get_config()
|
||||
local new_state = not conf.auto_show_menu
|
||||
conflict.configure({ auto_show_menu = new_state, auto_show_next_menu = new_state })
|
||||
utils.notify("Auto-show conflict menu " .. (new_state and "enabled" or "disabled"), vim.log.levels.INFO)
|
||||
end, { desc = "Toggle auto-show conflict menu after code injection" })
|
||||
|
||||
-- Initialize conflict module
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
conflict.setup()
|
||||
|
||||
-- Linter validation commands
|
||||
vim.api.nvim_create_user_command("CoderLintCheck", function()
|
||||
local linter = require("codetyper.features.agents.linter")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
linter.validate_after_injection(bufnr, nil, nil, function(result)
|
||||
if result then
|
||||
if not result.has_errors and not result.has_warnings then
|
||||
utils.notify("No lint errors found", vim.log.levels.INFO)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end, { desc = "Check current buffer for lint errors" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderLintFix", function()
|
||||
local linter = require("codetyper.features.agents.linter")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local result = linter.check_region(bufnr, 1, line_count)
|
||||
if result.has_errors or result.has_warnings then
|
||||
linter.request_ai_fix(bufnr, result)
|
||||
else
|
||||
utils.notify("No lint errors to fix", vim.log.levels.INFO)
|
||||
end
|
||||
end, { desc = "Request AI to fix lint errors in current buffer" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderLintQuickfix", function()
|
||||
local linter = require("codetyper.features.agents.linter")
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local result = linter.check_region(bufnr, 1, line_count)
|
||||
if #result.diagnostics > 0 then
|
||||
linter.show_in_quickfix(bufnr, result)
|
||||
else
|
||||
utils.notify("No lint errors to show", vim.log.levels.INFO)
|
||||
end
|
||||
end, { desc = "Show lint errors in quickfix list" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderLintToggleAuto", function()
|
||||
local conflict = require("codetyper.core.diff.conflict")
|
||||
local linter = require("codetyper.features.agents.linter")
|
||||
local linter_config = linter.get_config()
|
||||
local new_state = not linter_config.auto_save
|
||||
linter.configure({ auto_save = new_state })
|
||||
conflict.configure({ lint_after_accept = new_state, auto_fix_lint_errors = new_state })
|
||||
utils.notify("Auto lint check " .. (new_state and "enabled" or "disabled"), vim.log.levels.INFO)
|
||||
end, { desc = "Toggle automatic lint checking after code acceptance" })
|
||||
|
||||
-- Setup default keymaps
|
||||
M.setup_keymaps()
|
||||
end
|
||||
@@ -1476,12 +964,6 @@ function M.setup_keymaps()
|
||||
desc = "Coder: Transform all tags in file"
|
||||
})
|
||||
|
||||
-- Agent keymaps
|
||||
vim.keymap.set("n", "<leader>ca", "<cmd>CoderAgentToggle<CR>", {
|
||||
silent = true,
|
||||
desc = "Coder: Toggle Agent panel"
|
||||
})
|
||||
|
||||
-- Index keymap - open coder companion
|
||||
vim.keymap.set("n", "<leader>ci", "<cmd>CoderIndex<CR>", {
|
||||
silent = true,
|
||||
|
||||
@@ -1,907 +0,0 @@
|
||||
---@mod codetyper.agent.ui Agent chat UI for Codetyper.nvim
|
||||
---
|
||||
--- Provides a sidebar chat interface for agent interactions with real-time logs.
|
||||
|
||||
local M = {}
|
||||
|
||||
local agent = require("codetyper.features.agents")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local utils = require("codetyper.support.utils")
|
||||
|
||||
---@class AgentUIState
|
||||
---@field chat_buf number|nil Chat buffer
|
||||
---@field chat_win number|nil Chat window
|
||||
---@field input_buf number|nil Input buffer
|
||||
---@field input_win number|nil Input window
|
||||
---@field logs_buf number|nil Logs buffer
|
||||
---@field logs_win number|nil Logs window
|
||||
---@field is_open boolean Whether the UI is open
|
||||
---@field log_listener_id number|nil Listener ID for logs
|
||||
---@field referenced_files table Files referenced with @
|
||||
|
||||
local state = {
|
||||
chat_buf = nil,
|
||||
chat_win = nil,
|
||||
input_buf = nil,
|
||||
input_win = nil,
|
||||
logs_buf = nil,
|
||||
logs_win = nil,
|
||||
is_open = false,
|
||||
log_listener_id = nil,
|
||||
referenced_files = {},
|
||||
selection_context = nil, -- Visual selection passed when opening
|
||||
}
|
||||
|
||||
--- Namespace for highlights
|
||||
local ns_chat = vim.api.nvim_create_namespace("codetyper_agent_chat")
|
||||
local ns_logs = vim.api.nvim_create_namespace("codetyper_agent_logs")
|
||||
|
||||
--- Fixed heights
|
||||
local INPUT_HEIGHT = 5
|
||||
local LOGS_WIDTH = 50
|
||||
|
||||
--- Calculate dynamic width (1/4 of screen, minimum 30)
|
||||
---@return number
|
||||
local function get_panel_width()
|
||||
return math.max(math.floor(vim.o.columns * 0.25), 30)
|
||||
end
|
||||
|
||||
--- Autocmd group
|
||||
local agent_augroup = nil
|
||||
|
||||
--- Autocmd group for width maintenance
|
||||
local width_augroup = nil
|
||||
|
||||
--- Store target width
|
||||
local target_width = nil
|
||||
|
||||
--- Setup autocmd to always maintain 1/4 window width
|
||||
local function setup_width_autocmd()
|
||||
-- Clear previous autocmd group if exists
|
||||
if width_augroup then
|
||||
pcall(vim.api.nvim_del_augroup_by_id, width_augroup)
|
||||
end
|
||||
|
||||
width_augroup = vim.api.nvim_create_augroup("CodetypeAgentWidth", { clear = true })
|
||||
|
||||
-- Always maintain 1/4 width on any window event
|
||||
vim.api.nvim_create_autocmd({ "WinResized", "WinNew", "WinClosed", "VimResized" }, {
|
||||
group = width_augroup,
|
||||
callback = function()
|
||||
if not state.is_open or not state.chat_win then
|
||||
return
|
||||
end
|
||||
if not vim.api.nvim_win_is_valid(state.chat_win) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
|
||||
-- Always calculate 1/4 of current screen width
|
||||
local new_target = math.max(math.floor(vim.o.columns * 0.25), 30)
|
||||
target_width = new_target
|
||||
|
||||
local current_width = vim.api.nvim_win_get_width(state.chat_win)
|
||||
if current_width ~= target_width then
|
||||
pcall(vim.api.nvim_win_set_width, state.chat_win, target_width)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end,
|
||||
desc = "Maintain Agent panel at 1/4 window width",
|
||||
})
|
||||
end
|
||||
|
||||
--- Add a log entry to the logs buffer
|
||||
---@param entry table Log entry
|
||||
local function add_log_entry(entry)
|
||||
if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Handle clear event
|
||||
if entry.level == "clear" then
|
||||
vim.bo[state.logs_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, {
|
||||
"Logs",
|
||||
string.rep("─", LOGS_WIDTH - 2),
|
||||
"",
|
||||
})
|
||||
vim.bo[state.logs_buf].modifiable = false
|
||||
return
|
||||
end
|
||||
|
||||
vim.bo[state.logs_buf].modifiable = true
|
||||
|
||||
local formatted = logs.format_entry(entry)
|
||||
local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, -1, false)
|
||||
local line_num = #lines
|
||||
|
||||
-- Split formatted log into individual lines to avoid passing newline-containing items
|
||||
local formatted_lines = vim.split(formatted, "\n")
|
||||
vim.api.nvim_buf_set_lines(state.logs_buf, -1, -1, false, formatted_lines)
|
||||
|
||||
-- Apply highlighting based on level
|
||||
local hl_map = {
|
||||
info = "DiagnosticInfo",
|
||||
debug = "Comment",
|
||||
request = "DiagnosticWarn",
|
||||
response = "DiagnosticOk",
|
||||
tool = "DiagnosticHint",
|
||||
error = "DiagnosticError",
|
||||
}
|
||||
|
||||
local hl = hl_map[entry.level] or "Normal"
|
||||
vim.api.nvim_buf_add_highlight(state.logs_buf, ns_logs, hl, line_num, 0, -1)
|
||||
|
||||
vim.bo[state.logs_buf].modifiable = false
|
||||
|
||||
-- Auto-scroll logs
|
||||
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
|
||||
local new_count = vim.api.nvim_buf_line_count(state.logs_buf)
|
||||
pcall(vim.api.nvim_win_set_cursor, state.logs_win, { new_count, 0 })
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Add a message to the chat buffer
|
||||
---@param role string "user" | "assistant" | "tool" | "system"
|
||||
---@param content string Message content
|
||||
---@param highlight? string Optional highlight group
|
||||
local function add_message(role, content, highlight)
|
||||
if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.bo[state.chat_buf].modifiable = true
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false)
|
||||
local start_line = #lines
|
||||
|
||||
-- Add separator if not first message
|
||||
if start_line > 0 and lines[start_line] ~= "" then
|
||||
vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, { "" })
|
||||
start_line = start_line + 1
|
||||
end
|
||||
|
||||
-- Format the message
|
||||
local prefix_map = {
|
||||
user = ">>> You:",
|
||||
assistant = "<<< Agent:",
|
||||
tool = "[Tool]",
|
||||
system = "[System]",
|
||||
}
|
||||
|
||||
local prefix = prefix_map[role] or "[Unknown]"
|
||||
local message_lines = { prefix }
|
||||
|
||||
-- Split content into lines
|
||||
for line in content:gmatch("[^\n]+") do
|
||||
table.insert(message_lines, " " .. line)
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, message_lines)
|
||||
|
||||
-- Apply highlighting
|
||||
local hl_group = highlight or ({
|
||||
user = "DiagnosticInfo",
|
||||
assistant = "DiagnosticOk",
|
||||
tool = "DiagnosticWarn",
|
||||
system = "DiagnosticHint",
|
||||
})[role] or "Normal"
|
||||
|
||||
vim.api.nvim_buf_add_highlight(state.chat_buf, ns_chat, hl_group, start_line, 0, -1)
|
||||
|
||||
vim.bo[state.chat_buf].modifiable = false
|
||||
|
||||
-- Scroll to bottom
|
||||
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
|
||||
local line_count = vim.api.nvim_buf_line_count(state.chat_buf)
|
||||
pcall(vim.api.nvim_win_set_cursor, state.chat_win, { line_count, 0 })
|
||||
end
|
||||
end
|
||||
|
||||
--- Create the agent callbacks
|
||||
---@return table Callbacks for agent.run
|
||||
local function create_callbacks()
|
||||
return {
|
||||
on_text = function(text)
|
||||
vim.schedule(function()
|
||||
add_message("assistant", text)
|
||||
logs.thinking("Received response text")
|
||||
end)
|
||||
end,
|
||||
|
||||
on_tool_start = function(name)
|
||||
vim.schedule(function()
|
||||
add_message("tool", "Executing: " .. name .. "...", "DiagnosticWarn")
|
||||
logs.tool(name, "start")
|
||||
end)
|
||||
end,
|
||||
|
||||
on_tool_result = function(name, result)
|
||||
vim.schedule(function()
|
||||
local display_result = result
|
||||
if #result > 200 then
|
||||
display_result = result:sub(1, 200) .. "..."
|
||||
end
|
||||
add_message("tool", name .. ": " .. display_result, "DiagnosticOk")
|
||||
logs.tool(name, "success", string.format("%d bytes", #result))
|
||||
end)
|
||||
end,
|
||||
|
||||
on_complete = function()
|
||||
vim.schedule(function()
|
||||
local changes_count = agent.get_changes_count()
|
||||
if changes_count > 0 then
|
||||
add_message("system",
|
||||
string.format("Done. %d file(s) changed. Press <leader>d to review changes.", changes_count),
|
||||
"DiagnosticHint")
|
||||
logs.info(string.format("Agent completed with %d change(s)", changes_count))
|
||||
else
|
||||
add_message("system", "Done.", "DiagnosticHint")
|
||||
logs.info("Agent loop completed")
|
||||
end
|
||||
M.focus_input()
|
||||
end)
|
||||
end,
|
||||
|
||||
on_error = function(err)
|
||||
vim.schedule(function()
|
||||
add_message("system", "Error: " .. err, "DiagnosticError")
|
||||
logs.error(err)
|
||||
M.focus_input()
|
||||
end)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
--- Build file context from referenced files
|
||||
---@return string Context string
|
||||
local function build_file_context()
|
||||
local context = ""
|
||||
|
||||
for filename, filepath in pairs(state.referenced_files) do
|
||||
local content = utils.read_file(filepath)
|
||||
if content and content ~= "" then
|
||||
local ext = vim.fn.fnamemodify(filepath, ":e")
|
||||
context = context .. "\n\n=== FILE: " .. filename .. " ===\n"
|
||||
context = context .. "Path: " .. filepath .. "\n"
|
||||
context = context .. "```" .. (ext or "text") .. "\n" .. content .. "\n```\n"
|
||||
end
|
||||
end
|
||||
|
||||
return context
|
||||
end
|
||||
|
||||
--- Submit user input
|
||||
local function submit_input()
|
||||
if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false)
|
||||
local input = table.concat(lines, "\n")
|
||||
input = vim.trim(input)
|
||||
|
||||
if input == "" then
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear input buffer
|
||||
vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, { "" })
|
||||
|
||||
-- Handle special commands
|
||||
if input == "/stop" then
|
||||
agent.stop()
|
||||
add_message("system", "Stopped.")
|
||||
logs.info("Agent stopped by user")
|
||||
return
|
||||
end
|
||||
|
||||
if input == "/clear" then
|
||||
agent.reset()
|
||||
logs.clear()
|
||||
state.referenced_files = {}
|
||||
if state.chat_buf and vim.api.nvim_buf_is_valid(state.chat_buf) then
|
||||
vim.bo[state.chat_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, {
|
||||
"╔═══════════════════════════════════════════════════════════════╗",
|
||||
"║ [AGENT MODE] Can read/write files ║",
|
||||
"╠═══════════════════════════════════════════════════════════════╣",
|
||||
"║ @ attach | C-f current file | <leader>d review changes ║",
|
||||
"╚═══════════════════════════════════════════════════════════════╝",
|
||||
"",
|
||||
})
|
||||
vim.bo[state.chat_buf].modifiable = false
|
||||
end
|
||||
-- Also clear collected diffs
|
||||
local diff_review = require("codetyper.adapters.nvim.ui.diff_review")
|
||||
diff_review.clear()
|
||||
return
|
||||
end
|
||||
|
||||
if input == "/close" then
|
||||
M.close()
|
||||
return
|
||||
end
|
||||
|
||||
if input == "/continue" then
|
||||
if agent.is_running() then
|
||||
add_message("system", "Agent is already running. Use /stop first.")
|
||||
return
|
||||
end
|
||||
|
||||
if not agent.has_saved_session() then
|
||||
add_message("system", "No saved session to continue.")
|
||||
return
|
||||
end
|
||||
|
||||
local info = agent.get_saved_session_info()
|
||||
if info then
|
||||
add_message("system", string.format("Resuming session from %s...", info.saved_at))
|
||||
logs.info(string.format("Resuming: %d messages, iteration %d", info.messages, info.iteration))
|
||||
end
|
||||
|
||||
local success = agent.continue_session(create_callbacks())
|
||||
if not success then
|
||||
add_message("system", "Failed to resume session.")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Build file context
|
||||
local file_context = build_file_context()
|
||||
local file_count = vim.tbl_count(state.referenced_files)
|
||||
|
||||
-- Add user message to chat
|
||||
local display_input = input
|
||||
if file_count > 0 then
|
||||
local files_list = {}
|
||||
for fname, _ in pairs(state.referenced_files) do
|
||||
table.insert(files_list, fname)
|
||||
end
|
||||
display_input = input .. "\n[Attached: " .. table.concat(files_list, ", ") .. "]"
|
||||
end
|
||||
add_message("user", display_input)
|
||||
logs.info("User: " .. input:sub(1, 40) .. (input:len() > 40 and "..." or ""))
|
||||
|
||||
-- Clear referenced files after use
|
||||
state.referenced_files = {}
|
||||
|
||||
-- Check if agent is already running
|
||||
if agent.is_running() then
|
||||
add_message("system", "Busy. /stop first.")
|
||||
logs.info("Request rejected - busy")
|
||||
return
|
||||
end
|
||||
|
||||
-- Build context from current buffer
|
||||
local current_file = vim.fn.expand("#:p")
|
||||
if current_file == "" then
|
||||
current_file = vim.fn.expand("%:p")
|
||||
end
|
||||
|
||||
local llm = require("codetyper.core.llm")
|
||||
local context = {}
|
||||
|
||||
if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then
|
||||
context = llm.build_context(current_file, "agent")
|
||||
logs.debug("Context: " .. vim.fn.fnamemodify(current_file, ":t"))
|
||||
end
|
||||
|
||||
-- Append file context to input
|
||||
local full_input = input
|
||||
|
||||
-- Add selection context if present
|
||||
local selection_ctx = M.get_selection_context()
|
||||
if selection_ctx then
|
||||
full_input = full_input .. "\n\n" .. selection_ctx
|
||||
end
|
||||
|
||||
if file_context ~= "" then
|
||||
full_input = full_input .. "\n\nATTACHED FILES:" .. file_context
|
||||
end
|
||||
|
||||
logs.thinking("Starting...")
|
||||
|
||||
-- Run the agent
|
||||
agent.run(full_input, context, create_callbacks())
|
||||
end
|
||||
|
||||
--- Show file picker for @ mentions
|
||||
function M.show_file_picker()
|
||||
local has_telescope, telescope = pcall(require, "telescope.builtin")
|
||||
|
||||
if has_telescope then
|
||||
telescope.find_files({
|
||||
prompt_title = "Attach file (@)",
|
||||
attach_mappings = function(prompt_bufnr, map)
|
||||
local actions = require("telescope.actions")
|
||||
local action_state = require("telescope.actions.state")
|
||||
|
||||
actions.select_default:replace(function()
|
||||
actions.close(prompt_bufnr)
|
||||
local selection = action_state.get_selected_entry()
|
||||
if selection then
|
||||
local filepath = selection.path or selection[1]
|
||||
local filename = vim.fn.fnamemodify(filepath, ":t")
|
||||
M.add_file_reference(filepath, filename)
|
||||
end
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
})
|
||||
else
|
||||
vim.ui.input({ prompt = "File path: " }, function(input)
|
||||
if input and input ~= "" then
|
||||
local filepath = vim.fn.fnamemodify(input, ":p")
|
||||
local filename = vim.fn.fnamemodify(filepath, ":t")
|
||||
M.add_file_reference(filepath, filename)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
--- Add a file reference
|
||||
---@param filepath string Full path to the file
|
||||
---@param filename string Display name
|
||||
function M.add_file_reference(filepath, filename)
|
||||
filepath = vim.fn.fnamemodify(filepath, ":p")
|
||||
state.referenced_files[filename] = filepath
|
||||
|
||||
local content = utils.read_file(filepath)
|
||||
if not content then
|
||||
utils.notify("Cannot read: " .. filename, vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
add_message("system", "Attached: " .. filename, "DiagnosticHint")
|
||||
logs.debug("Attached: " .. filename)
|
||||
M.focus_input()
|
||||
end
|
||||
|
||||
--- Include current file context
|
||||
function M.include_current_file()
|
||||
-- Get the file from the window that's not the agent sidebar
|
||||
local current_file = nil
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
if win ~= state.chat_win and win ~= state.logs_win and win ~= state.input_win then
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
local name = vim.api.nvim_buf_get_name(buf)
|
||||
if name ~= "" and vim.fn.filereadable(name) == 1 then
|
||||
current_file = name
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not current_file then
|
||||
utils.notify("No file to attach", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local filename = vim.fn.fnamemodify(current_file, ":t")
|
||||
M.add_file_reference(current_file, filename)
|
||||
end
|
||||
|
||||
--- Focus the input buffer
|
||||
function M.focus_input()
|
||||
if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then
|
||||
vim.api.nvim_set_current_win(state.input_win)
|
||||
vim.cmd("startinsert")
|
||||
end
|
||||
end
|
||||
|
||||
--- Focus the chat buffer
|
||||
function M.focus_chat()
|
||||
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
|
||||
vim.api.nvim_set_current_win(state.chat_win)
|
||||
end
|
||||
end
|
||||
|
||||
--- Focus the logs buffer
|
||||
function M.focus_logs()
|
||||
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
|
||||
vim.api.nvim_set_current_win(state.logs_win)
|
||||
end
|
||||
end
|
||||
|
||||
--- Show chat mode switcher modal
|
||||
function M.show_chat_switcher()
|
||||
local switcher = require("codetyper.chat_switcher")
|
||||
switcher.show()
|
||||
end
|
||||
|
||||
--- Update the logs title with token counts
|
||||
local function update_logs_title()
|
||||
if not state.logs_win or not vim.api.nvim_win_is_valid(state.logs_win) then
|
||||
return
|
||||
end
|
||||
|
||||
local prompt_tokens, response_tokens = logs.get_token_totals()
|
||||
local provider, _ = logs.get_provider_info()
|
||||
|
||||
if provider and state.logs_buf and vim.api.nvim_buf_is_valid(state.logs_buf) then
|
||||
vim.bo[state.logs_buf].modifiable = true
|
||||
local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, 2, false)
|
||||
if #lines >= 1 then
|
||||
lines[1] = string.format("%s | %d/%d tokens", provider:upper(), prompt_tokens, response_tokens)
|
||||
vim.api.nvim_buf_set_lines(state.logs_buf, 0, 1, false, { lines[1] })
|
||||
end
|
||||
vim.bo[state.logs_buf].modifiable = false
|
||||
end
|
||||
end
|
||||
|
||||
--- Open the agent UI
|
||||
---@param selection table|nil Visual selection context {text, start_line, end_line, filepath, filename, language}
|
||||
function M.open(selection)
|
||||
if state.is_open then
|
||||
-- If already open and new selection provided, add it as context
|
||||
if selection and selection.text and selection.text ~= "" then
|
||||
M.add_selection_context(selection)
|
||||
end
|
||||
M.focus_input()
|
||||
return
|
||||
end
|
||||
|
||||
-- Store selection context
|
||||
state.selection_context = selection
|
||||
|
||||
-- Clear previous state
|
||||
logs.clear()
|
||||
state.referenced_files = {}
|
||||
|
||||
-- Create chat buffer
|
||||
state.chat_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.chat_buf].buftype = "nofile"
|
||||
vim.bo[state.chat_buf].bufhidden = "hide"
|
||||
vim.bo[state.chat_buf].swapfile = false
|
||||
vim.bo[state.chat_buf].filetype = "markdown"
|
||||
|
||||
-- Create input buffer
|
||||
state.input_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.input_buf].buftype = "nofile"
|
||||
vim.bo[state.input_buf].bufhidden = "hide"
|
||||
vim.bo[state.input_buf].swapfile = false
|
||||
|
||||
-- Create logs buffer
|
||||
state.logs_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.logs_buf].buftype = "nofile"
|
||||
vim.bo[state.logs_buf].bufhidden = "hide"
|
||||
vim.bo[state.logs_buf].swapfile = false
|
||||
|
||||
-- Create chat window on the LEFT (like NvimTree)
|
||||
vim.cmd("topleft vsplit")
|
||||
state.chat_win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.chat_win, state.chat_buf)
|
||||
vim.api.nvim_win_set_width(state.chat_win, get_panel_width())
|
||||
|
||||
-- Window options for chat
|
||||
vim.wo[state.chat_win].number = false
|
||||
vim.wo[state.chat_win].relativenumber = false
|
||||
vim.wo[state.chat_win].signcolumn = "no"
|
||||
vim.wo[state.chat_win].wrap = true
|
||||
vim.wo[state.chat_win].linebreak = true
|
||||
vim.wo[state.chat_win].winfixwidth = true
|
||||
vim.wo[state.chat_win].cursorline = false
|
||||
|
||||
-- Create input window below chat
|
||||
vim.cmd("belowright split")
|
||||
state.input_win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.input_win, state.input_buf)
|
||||
vim.api.nvim_win_set_height(state.input_win, INPUT_HEIGHT)
|
||||
|
||||
-- Window options for input
|
||||
vim.wo[state.input_win].number = false
|
||||
vim.wo[state.input_win].relativenumber = false
|
||||
vim.wo[state.input_win].signcolumn = "no"
|
||||
vim.wo[state.input_win].wrap = true
|
||||
vim.wo[state.input_win].linebreak = true
|
||||
vim.wo[state.input_win].winfixheight = true
|
||||
vim.wo[state.input_win].winfixwidth = true
|
||||
|
||||
-- Create logs window on the RIGHT
|
||||
vim.cmd("botright vsplit")
|
||||
state.logs_win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.logs_win, state.logs_buf)
|
||||
vim.api.nvim_win_set_width(state.logs_win, LOGS_WIDTH)
|
||||
|
||||
-- Window options for logs
|
||||
vim.wo[state.logs_win].number = false
|
||||
vim.wo[state.logs_win].relativenumber = false
|
||||
vim.wo[state.logs_win].signcolumn = "no"
|
||||
vim.wo[state.logs_win].wrap = true
|
||||
vim.wo[state.logs_win].linebreak = true
|
||||
vim.wo[state.logs_win].winfixwidth = true
|
||||
vim.wo[state.logs_win].cursorline = false
|
||||
|
||||
-- Set initial content for chat
|
||||
vim.bo[state.chat_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, {
|
||||
"╔═══════════════════════════════════════════════════════════════╗",
|
||||
"║ [AGENT MODE] Can read/write files ║",
|
||||
"╠═══════════════════════════════════════════════════════════════╣",
|
||||
"║ @ attach | C-f current file | <leader>d review changes ║",
|
||||
"╚═══════════════════════════════════════════════════════════════╝",
|
||||
"",
|
||||
})
|
||||
vim.bo[state.chat_buf].modifiable = false
|
||||
|
||||
-- Set initial content for logs
|
||||
vim.bo[state.logs_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, {
|
||||
"Logs",
|
||||
string.rep("─", LOGS_WIDTH - 2),
|
||||
"",
|
||||
})
|
||||
vim.bo[state.logs_buf].modifiable = false
|
||||
|
||||
-- Register log listener
|
||||
state.log_listener_id = logs.add_listener(function(entry)
|
||||
add_log_entry(entry)
|
||||
if entry.level == "response" then
|
||||
vim.schedule(update_logs_title)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Set up keymaps for input buffer
|
||||
local input_opts = { buffer = state.input_buf, noremap = true, silent = true }
|
||||
|
||||
vim.keymap.set("i", "<CR>", submit_input, input_opts)
|
||||
vim.keymap.set("n", "<CR>", submit_input, input_opts)
|
||||
vim.keymap.set("i", "@", M.show_file_picker, input_opts)
|
||||
vim.keymap.set({ "n", "i" }, "<C-f>", M.include_current_file, input_opts)
|
||||
vim.keymap.set("n", "<Tab>", M.focus_chat, input_opts)
|
||||
vim.keymap.set("n", "q", M.close, input_opts)
|
||||
vim.keymap.set("n", "<Esc>", M.close, input_opts)
|
||||
vim.keymap.set("n", "<leader>d", M.show_diff_review, input_opts)
|
||||
|
||||
-- Set up keymaps for chat buffer
|
||||
local chat_opts = { buffer = state.chat_buf, noremap = true, silent = true }
|
||||
|
||||
vim.keymap.set("n", "i", M.focus_input, chat_opts)
|
||||
vim.keymap.set("n", "<CR>", M.focus_input, chat_opts)
|
||||
vim.keymap.set("n", "@", M.show_file_picker, chat_opts)
|
||||
vim.keymap.set("n", "<C-f>", M.include_current_file, chat_opts)
|
||||
vim.keymap.set("n", "<Tab>", M.focus_logs, chat_opts)
|
||||
vim.keymap.set("n", "q", M.close, chat_opts)
|
||||
vim.keymap.set("n", "<leader>d", M.show_diff_review, chat_opts)
|
||||
|
||||
-- Set up keymaps for logs buffer
|
||||
local logs_opts = { buffer = state.logs_buf, noremap = true, silent = true }
|
||||
|
||||
vim.keymap.set("n", "<Tab>", M.focus_input, logs_opts)
|
||||
vim.keymap.set("n", "q", M.close, logs_opts)
|
||||
vim.keymap.set("n", "i", M.focus_input, logs_opts)
|
||||
|
||||
-- Setup autocmd for cleanup
|
||||
agent_augroup = vim.api.nvim_create_augroup("CodetypeAgentUI", { clear = true })
|
||||
|
||||
vim.api.nvim_create_autocmd("WinClosed", {
|
||||
group = agent_augroup,
|
||||
callback = function(args)
|
||||
local closed_win = tonumber(args.match)
|
||||
if closed_win == state.chat_win or closed_win == state.logs_win or closed_win == state.input_win then
|
||||
vim.schedule(function()
|
||||
M.close()
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- Setup autocmd to maintain 1/4 width
|
||||
target_width = get_panel_width()
|
||||
setup_width_autocmd()
|
||||
|
||||
state.is_open = true
|
||||
|
||||
-- Focus input and log startup
|
||||
M.focus_input()
|
||||
logs.info("Agent ready")
|
||||
|
||||
-- Check for saved session and notify user
|
||||
if agent.has_saved_session() then
|
||||
vim.schedule(function()
|
||||
local info = agent.get_saved_session_info()
|
||||
if info then
|
||||
add_message("system",
|
||||
string.format("Saved session available (%s). Type /continue to resume.", info.saved_at),
|
||||
"DiagnosticHint")
|
||||
logs.info("Saved session found: " .. (info.prompt or ""):sub(1, 30) .. "...")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- If we have a selection, show it as context
|
||||
if selection and selection.text and selection.text ~= "" then
|
||||
vim.schedule(function()
|
||||
M.add_selection_context(selection)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Log provider info
|
||||
local ok, codetyper = pcall(require, "codetyper")
|
||||
if ok then
|
||||
local config = codetyper.get_config()
|
||||
local provider = config.llm.provider
|
||||
local model = "unknown"
|
||||
if provider == "ollama" then
|
||||
model = config.llm.ollama.model
|
||||
elseif provider == "openai" then
|
||||
model = config.llm.openai.model
|
||||
elseif provider == "gemini" then
|
||||
model = config.llm.gemini.model
|
||||
elseif provider == "copilot" then
|
||||
model = config.llm.copilot.model
|
||||
end
|
||||
logs.info(string.format("%s (%s)", provider, model))
|
||||
end
|
||||
end
|
||||
|
||||
--- Close the agent UI
|
||||
function M.close()
|
||||
if not state.is_open then
|
||||
return
|
||||
end
|
||||
|
||||
-- Stop agent if running
|
||||
if agent.is_running() then
|
||||
agent.stop()
|
||||
end
|
||||
|
||||
-- Remove log listener
|
||||
if state.log_listener_id then
|
||||
logs.remove_listener(state.log_listener_id)
|
||||
state.log_listener_id = nil
|
||||
end
|
||||
|
||||
-- Remove autocmd
|
||||
if agent_augroup then
|
||||
pcall(vim.api.nvim_del_augroup_by_id, agent_augroup)
|
||||
agent_augroup = nil
|
||||
end
|
||||
|
||||
-- Close windows
|
||||
if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then
|
||||
pcall(vim.api.nvim_win_close, state.input_win, true)
|
||||
end
|
||||
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
|
||||
pcall(vim.api.nvim_win_close, state.chat_win, true)
|
||||
end
|
||||
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
|
||||
pcall(vim.api.nvim_win_close, state.logs_win, true)
|
||||
end
|
||||
|
||||
-- Reset state
|
||||
state.chat_buf = nil
|
||||
state.chat_win = nil
|
||||
state.input_buf = nil
|
||||
state.input_win = nil
|
||||
state.logs_buf = nil
|
||||
state.logs_win = nil
|
||||
state.is_open = false
|
||||
state.referenced_files = {}
|
||||
|
||||
-- Reset agent conversation
|
||||
agent.reset()
|
||||
end
|
||||
|
||||
--- Toggle the agent UI
|
||||
function M.toggle()
|
||||
if state.is_open then
|
||||
M.close()
|
||||
else
|
||||
M.open()
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if UI is open
|
||||
---@return boolean
|
||||
function M.is_open()
|
||||
return state.is_open
|
||||
end
|
||||
|
||||
--- Show the diff review for all changes made in this session
|
||||
function M.show_diff_review()
|
||||
local changes_count = agent.get_changes_count()
|
||||
if changes_count == 0 then
|
||||
utils.notify("No changes to review", vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
agent.show_diff_review()
|
||||
end
|
||||
|
||||
--- Add visual selection as context in the chat
|
||||
---@param selection table Selection info {text, start_line, end_line, filepath, filename, language}
|
||||
function M.add_selection_context(selection)
|
||||
if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
state.selection_context = selection
|
||||
|
||||
vim.bo[state.chat_buf].modifiable = true
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false)
|
||||
|
||||
-- Format the selection display
|
||||
local location = ""
|
||||
if selection.filename then
|
||||
location = selection.filename
|
||||
if selection.start_line then
|
||||
location = location .. ":" .. selection.start_line
|
||||
if selection.end_line and selection.end_line ~= selection.start_line then
|
||||
location = location .. "-" .. selection.end_line
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local new_lines = {
|
||||
"",
|
||||
"┌─ Selected Code ─────────────────────",
|
||||
"│ " .. location,
|
||||
"│",
|
||||
}
|
||||
|
||||
-- Add the selected code
|
||||
for _, line in ipairs(vim.split(selection.text, "\n")) do
|
||||
table.insert(new_lines, "│ " .. line)
|
||||
end
|
||||
|
||||
table.insert(new_lines, "│")
|
||||
table.insert(new_lines, "└──────────────────────────────────────")
|
||||
table.insert(new_lines, "")
|
||||
table.insert(new_lines, "Describe what you'd like to do with this code.")
|
||||
|
||||
for _, line in ipairs(new_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, lines)
|
||||
vim.bo[state.chat_buf].modifiable = false
|
||||
|
||||
-- Scroll to bottom
|
||||
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
|
||||
local line_count = vim.api.nvim_buf_line_count(state.chat_buf)
|
||||
vim.api.nvim_win_set_cursor(state.chat_win, { line_count, 0 })
|
||||
end
|
||||
|
||||
-- Also add the file to referenced_files for context
|
||||
if selection.filepath and selection.filepath ~= "" then
|
||||
state.referenced_files[selection.filename or "selection"] = selection.filepath
|
||||
end
|
||||
|
||||
logs.info("Selection added: " .. location)
|
||||
end
|
||||
|
||||
--- Get selection context for agent prompt
|
||||
---@return string|nil Selection context string
|
||||
function M.get_selection_context()
|
||||
if not state.selection_context or not state.selection_context.text then
|
||||
return nil
|
||||
end
|
||||
|
||||
local sel = state.selection_context
|
||||
local location = sel.filename or "unknown"
|
||||
if sel.start_line then
|
||||
location = location .. ":" .. sel.start_line
|
||||
if sel.end_line and sel.end_line ~= sel.start_line then
|
||||
location = location .. "-" .. sel.end_line
|
||||
end
|
||||
end
|
||||
|
||||
return string.format(
|
||||
"SELECTED CODE (%s):\n```%s\n%s\n```",
|
||||
location,
|
||||
sel.language or "",
|
||||
sel.text
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,381 +0,0 @@
|
||||
---@mod codetyper.agent.context_modal Modal for additional context input
|
||||
---@brief [[
|
||||
--- Opens a floating window for user to provide additional context
|
||||
--- when the LLM requests more information.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ContextModalState
|
||||
---@field buf number|nil Buffer number
|
||||
---@field win number|nil Window number
|
||||
---@field original_event table|nil Original prompt event
|
||||
---@field callback function|nil Callback with additional context
|
||||
---@field llm_response string|nil LLM's response asking for context
|
||||
|
||||
local state = {
|
||||
buf = nil,
|
||||
win = nil,
|
||||
original_event = nil,
|
||||
callback = nil,
|
||||
llm_response = nil,
|
||||
attached_files = nil,
|
||||
}
|
||||
|
||||
--- Close the context modal
|
||||
function M.close()
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
vim.api.nvim_win_close(state.win, true)
|
||||
end
|
||||
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
|
||||
vim.api.nvim_buf_delete(state.buf, { force = true })
|
||||
end
|
||||
state.win = nil
|
||||
state.buf = nil
|
||||
state.original_event = nil
|
||||
state.callback = nil
|
||||
state.llm_response = nil
|
||||
end
|
||||
|
||||
--- Submit the additional context
|
||||
local function submit()
|
||||
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false)
|
||||
local additional_context = table.concat(lines, "\n")
|
||||
|
||||
-- Trim whitespace
|
||||
additional_context = additional_context:match("^%s*(.-)%s*$") or additional_context
|
||||
|
||||
if additional_context == "" then
|
||||
M.close()
|
||||
return
|
||||
end
|
||||
|
||||
local original_event = state.original_event
|
||||
local callback = state.callback
|
||||
|
||||
M.close()
|
||||
|
||||
if callback and original_event then
|
||||
-- Pass attached_files as third optional parameter
|
||||
callback(original_event, additional_context, state.attached_files)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
--- Parse requested file paths from LLM response and resolve to full paths
|
||||
local function parse_requested_files(response)
|
||||
if not response or response == "" then
|
||||
return {}
|
||||
end
|
||||
|
||||
local cwd = vim.fn.getcwd()
|
||||
local candidates = {}
|
||||
local seen = {}
|
||||
|
||||
for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do
|
||||
if not seen[path] then
|
||||
table.insert(candidates, path)
|
||||
seen[path] = true
|
||||
end
|
||||
end
|
||||
for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do
|
||||
if not seen[path] then
|
||||
table.insert(candidates, path)
|
||||
seen[path] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Resolve to full paths using cwd and glob
|
||||
local resolved = {}
|
||||
for _, p in ipairs(candidates) do
|
||||
local full = nil
|
||||
if p:sub(1,1) == "/" and vim.fn.filereadable(p) == 1 then
|
||||
full = p
|
||||
else
|
||||
local try1 = cwd .. "/" .. p
|
||||
if vim.fn.filereadable(try1) == 1 then
|
||||
full = try1
|
||||
else
|
||||
local tail = p:match("[^/]+$") or p
|
||||
local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true)
|
||||
if matches and #matches > 0 then
|
||||
full = matches[1]
|
||||
end
|
||||
end
|
||||
end
|
||||
if full and vim.fn.filereadable(full) == 1 then
|
||||
table.insert(resolved, full)
|
||||
end
|
||||
end
|
||||
return resolved
|
||||
end
|
||||
|
||||
|
||||
--- Attach parsed files into the modal buffer and remember them for submission
|
||||
local function attach_requested_files()
|
||||
if not state.llm_response or state.llm_response == "" then
|
||||
return
|
||||
end
|
||||
local files = parse_requested_files(state.llm_response)
|
||||
if #files == 0 then
|
||||
local ui_prompts = require("codetyper.prompts.agents.modal").ui
|
||||
vim.api.nvim_buf_set_lines(state.buf, vim.api.nvim_buf_line_count(state.buf), -1, false, ui_prompts.files_header)
|
||||
return
|
||||
end
|
||||
|
||||
state.attached_files = state.attached_files or {}
|
||||
|
||||
for _, full in ipairs(files) do
|
||||
local ok, lines = pcall(vim.fn.readfile, full)
|
||||
if ok and lines and #lines > 0 then
|
||||
table.insert(state.attached_files, { path = vim.fn.fnamemodify(full, ":~:." ) , full_path = full, content = table.concat(lines, "\n") })
|
||||
local insert_at = vim.api.nvim_buf_line_count(state.buf)
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Attached: " .. full .. " --" })
|
||||
for i, l in ipairs(lines) do
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at + 1 + i, insert_at + 1 + i, false, { l })
|
||||
end
|
||||
else
|
||||
local insert_at = vim.api.nvim_buf_line_count(state.buf)
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Failed to read: " .. full .. " --" })
|
||||
end
|
||||
end
|
||||
-- Move cursor to end and enter insert mode
|
||||
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
|
||||
vim.cmd("startinsert")
|
||||
end
|
||||
|
||||
--- Open the context modal
|
||||
---@param original_event table Original prompt event
|
||||
---@param llm_response string LLM's response asking for context
|
||||
---@param callback function(event: table, additional_context: string, attached_files?: table)
|
||||
---@param suggested_commands table[]|nil Optional list of {label,cmd} suggested shell commands
|
||||
function M.open(original_event, llm_response, callback, suggested_commands)
|
||||
-- Close any existing modal
|
||||
M.close()
|
||||
|
||||
state.original_event = original_event
|
||||
state.llm_response = llm_response
|
||||
state.callback = callback
|
||||
|
||||
-- Calculate window size
|
||||
local width = math.min(80, vim.o.columns - 10)
|
||||
local height = 10
|
||||
|
||||
-- Create buffer
|
||||
state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.buf].buftype = "nofile"
|
||||
vim.bo[state.buf].bufhidden = "wipe"
|
||||
vim.bo[state.buf].filetype = "markdown"
|
||||
|
||||
-- Create window
|
||||
local row = math.floor((vim.o.lines - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
|
||||
state.win = vim.api.nvim_open_win(state.buf, true, {
|
||||
relative = "editor",
|
||||
row = row,
|
||||
col = col,
|
||||
width = width,
|
||||
height = height,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
title = " Additional Context Needed ",
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
-- Set window options
|
||||
vim.wo[state.win].wrap = true
|
||||
vim.wo[state.win].cursorline = true
|
||||
|
||||
local ui_prompts = require("codetyper.prompts.agents.modal").ui
|
||||
|
||||
-- Add header showing what the LLM said
|
||||
local header_lines = {
|
||||
ui_prompts.llm_response_header,
|
||||
}
|
||||
|
||||
-- Truncate LLM response for display
|
||||
local response_preview = llm_response or ""
|
||||
if #response_preview > 200 then
|
||||
response_preview = response_preview:sub(1, 200) .. "..."
|
||||
end
|
||||
for line in response_preview:gmatch("[^\n]+") do
|
||||
table.insert(header_lines, "-- " .. line)
|
||||
end
|
||||
|
||||
-- If suggested commands were provided, show them in the header
|
||||
if suggested_commands and #suggested_commands > 0 then
|
||||
table.insert(header_lines, "")
|
||||
table.insert(header_lines, ui_prompts.suggested_commands_header)
|
||||
for i, s in ipairs(suggested_commands) do
|
||||
local label = s.label or s.cmd
|
||||
table.insert(header_lines, string.format("[%d] %s: %s", i, label, s.cmd))
|
||||
end
|
||||
table.insert(header_lines, ui_prompts.commands_hint)
|
||||
end
|
||||
|
||||
table.insert(header_lines, "")
|
||||
table.insert(header_lines, ui_prompts.input_header)
|
||||
table.insert(header_lines, "")
|
||||
|
||||
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, header_lines)
|
||||
|
||||
-- Move cursor to the end
|
||||
vim.api.nvim_win_set_cursor(state.win, { #header_lines, 0 })
|
||||
|
||||
-- Set up keymaps
|
||||
local opts = { buffer = state.buf, noremap = true, silent = true }
|
||||
|
||||
-- Submit with Ctrl+Enter or <leader>s
|
||||
vim.keymap.set("n", "<C-CR>", submit, opts)
|
||||
vim.keymap.set("i", "<C-CR>", submit, opts)
|
||||
vim.keymap.set("n", "<leader>s", submit, opts)
|
||||
vim.keymap.set("n", "<CR><CR>", submit, opts)
|
||||
|
||||
-- Attach parsed files (from LLM response)
|
||||
vim.keymap.set("n", "a", function()
|
||||
attach_requested_files()
|
||||
end, opts)
|
||||
|
||||
-- Confirm and submit with 'c' (convenient when doing question round)
|
||||
vim.keymap.set("n", "c", submit, opts)
|
||||
|
||||
-- Quick run of project inspection from modal with <leader>r / <C-r> in insert mode
|
||||
vim.keymap.set("n", "<leader>r", run_project_inspect, opts)
|
||||
vim.keymap.set("i", "<C-r>", function()
|
||||
vim.schedule(run_project_inspect)
|
||||
end, { buffer = state.buf, noremap = true, silent = true })
|
||||
|
||||
-- If suggested commands provided, create per-command keymaps <leader>1..n to run them
|
||||
state.suggested_commands = suggested_commands
|
||||
if suggested_commands and #suggested_commands > 0 then
|
||||
for i, s in ipairs(suggested_commands) do
|
||||
local key = "<leader>" .. tostring(i)
|
||||
vim.keymap.set("n", key, function()
|
||||
-- run this single command and append output
|
||||
if not s or not s.cmd then
|
||||
return
|
||||
end
|
||||
local ok, out = pcall(vim.fn.systemlist, s.cmd)
|
||||
local insert_at = vim.api.nvim_buf_line_count(state.buf)
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" })
|
||||
if ok and out and #out > 0 then
|
||||
for j, line in ipairs(out) do
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line })
|
||||
end
|
||||
else
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at + 1, insert_at + 1, false, { "(no output or command failed)" })
|
||||
end
|
||||
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
|
||||
vim.cmd("startinsert")
|
||||
end, opts)
|
||||
end
|
||||
-- Also map <leader>0 to run all suggested commands
|
||||
vim.keymap.set("n", "<leader>0", function()
|
||||
for _, s in ipairs(suggested_commands) do
|
||||
pcall(function()
|
||||
local ok, out = pcall(vim.fn.systemlist, s.cmd)
|
||||
local insert_at = vim.api.nvim_buf_line_count(state.buf)
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" })
|
||||
if ok and out and #out > 0 then
|
||||
for j, line in ipairs(out) do
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line })
|
||||
end
|
||||
else
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_at + 1, insert_at + 1, false, { "(no output or command failed)" })
|
||||
end
|
||||
end)
|
||||
end
|
||||
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
|
||||
vim.cmd("startinsert")
|
||||
end, opts)
|
||||
end
|
||||
|
||||
-- Close with Esc or q
|
||||
vim.keymap.set("n", "<Esc>", M.close, opts)
|
||||
vim.keymap.set("n", "q", M.close, opts)
|
||||
|
||||
-- Start in insert mode
|
||||
vim.cmd("startinsert")
|
||||
|
||||
-- Log
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = "Context modal opened - waiting for user input",
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
--- Run a small set of safe project inspection commands and insert outputs into the modal buffer
|
||||
local function run_project_inspect()
|
||||
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local cmds = {
|
||||
{ label = "List files (ls -la)", cmd = "ls -la" },
|
||||
{ label = "Git status (git status --porcelain)", cmd = "git status --porcelain" },
|
||||
{ label = "Git top (git rev-parse --show-toplevel)", cmd = "git rev-parse --show-toplevel" },
|
||||
{ label = "Show repo files (git ls-files)", cmd = "git ls-files" },
|
||||
}
|
||||
|
||||
local ui_prompts = require("codetyper.prompts.agents.modal").ui
|
||||
local insert_pos = vim.api.nvim_buf_line_count(state.buf)
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_pos, insert_pos, false, ui_prompts.project_inspect_header)
|
||||
|
||||
for _, c in ipairs(cmds) do
|
||||
local ok, out = pcall(vim.fn.systemlist, c.cmd)
|
||||
if ok and out and #out > 0 then
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --" })
|
||||
for i, line in ipairs(out) do
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2 + i, insert_pos + 2 + i, false, { line })
|
||||
end
|
||||
insert_pos = vim.api.nvim_buf_line_count(state.buf)
|
||||
else
|
||||
vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --", "(no output or command failed)" })
|
||||
insert_pos = vim.api.nvim_buf_line_count(state.buf)
|
||||
end
|
||||
end
|
||||
|
||||
-- Move cursor to end
|
||||
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
|
||||
vim.cmd("startinsert")
|
||||
end
|
||||
|
||||
-- Provide a keybinding in the modal to run project inspection commands
|
||||
pcall(function()
|
||||
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
|
||||
vim.keymap.set("n", "<leader>r", run_project_inspect, { buffer = state.buf, noremap = true, silent = true })
|
||||
vim.keymap.set("i", "<C-r>", function()
|
||||
vim.schedule(run_project_inspect)
|
||||
end, { buffer = state.buf, noremap = true, silent = true })
|
||||
end
|
||||
end)
|
||||
|
||||
--- Check if modal is open
|
||||
---@return boolean
|
||||
function M.is_open()
|
||||
return state.win ~= nil and vim.api.nvim_win_is_valid(state.win)
|
||||
end
|
||||
|
||||
--- Setup autocmds for the context modal
|
||||
function M.setup()
|
||||
local group = vim.api.nvim_create_augroup("CodetypeContextModal", { clear = true })
|
||||
|
||||
-- Close context modal when exiting Neovim
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
M.close()
|
||||
end,
|
||||
desc = "Close context modal before exiting Neovim",
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,386 +0,0 @@
|
||||
---@mod codetyper.agent.diff_review Diff review UI for agent changes
|
||||
---
|
||||
--- Provides a lazygit-style window interface for reviewing all changes
|
||||
--- made during an agent session.
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
local prompts = require("codetyper.prompts.agents.diff")
|
||||
|
||||
|
||||
---@class DiffEntry
|
||||
---@field path string File path
|
||||
---@field operation string "create"|"edit"|"delete"
|
||||
---@field original string|nil Original content (nil for new files)
|
||||
---@field modified string New/modified content
|
||||
---@field approved boolean Whether change was approved
|
||||
---@field applied boolean Whether change was applied
|
||||
|
||||
---@class DiffReviewState
|
||||
---@field entries DiffEntry[] List of changes
|
||||
---@field current_index number Currently selected entry
|
||||
---@field list_buf number|nil File list buffer
|
||||
---@field list_win number|nil File list window
|
||||
---@field diff_buf number|nil Diff view buffer
|
||||
---@field diff_win number|nil Diff view window
|
||||
---@field is_open boolean Whether review UI is open
|
||||
|
||||
local state = {
|
||||
entries = {},
|
||||
current_index = 1,
|
||||
list_buf = nil,
|
||||
list_win = nil,
|
||||
diff_buf = nil,
|
||||
diff_win = nil,
|
||||
is_open = false,
|
||||
}
|
||||
|
||||
--- Clear all collected diffs
|
||||
function M.clear()
|
||||
state.entries = {}
|
||||
state.current_index = 1
|
||||
end
|
||||
|
||||
--- Add a diff entry
|
||||
---@param entry DiffEntry
|
||||
function M.add(entry)
|
||||
table.insert(state.entries, entry)
|
||||
end
|
||||
|
||||
--- Get all entries
|
||||
---@return DiffEntry[]
|
||||
function M.get_entries()
|
||||
return state.entries
|
||||
end
|
||||
|
||||
--- Get entry count
|
||||
---@return number
|
||||
function M.count()
|
||||
return #state.entries
|
||||
end
|
||||
|
||||
--- Generate unified diff between two strings
|
||||
---@param original string|nil
|
||||
---@param modified string
|
||||
---@param filepath string
|
||||
---@return string[]
|
||||
local function generate_diff_lines(original, modified, filepath)
|
||||
local lines = {}
|
||||
local filename = vim.fn.fnamemodify(filepath, ":t")
|
||||
|
||||
if not original then
|
||||
-- New file
|
||||
table.insert(lines, "--- /dev/null")
|
||||
table.insert(lines, "+++ b/" .. filename)
|
||||
table.insert(lines, "@@ -0,0 +1," .. #vim.split(modified, "\n") .. " @@")
|
||||
for _, line in ipairs(vim.split(modified, "\n")) do
|
||||
table.insert(lines, "+" .. line)
|
||||
end
|
||||
else
|
||||
-- Modified file - use vim's diff
|
||||
table.insert(lines, "--- a/" .. filename)
|
||||
table.insert(lines, "+++ b/" .. filename)
|
||||
|
||||
local orig_lines = vim.split(original, "\n")
|
||||
local mod_lines = vim.split(modified, "\n")
|
||||
|
||||
-- Simple diff: show removed and added lines
|
||||
local max_lines = math.max(#orig_lines, #mod_lines)
|
||||
local context_start = 1
|
||||
local in_change = false
|
||||
|
||||
for i = 1, max_lines do
|
||||
local orig = orig_lines[i] or ""
|
||||
local mod = mod_lines[i] or ""
|
||||
|
||||
if orig ~= mod then
|
||||
if not in_change then
|
||||
table.insert(lines, string.format("@@ -%d,%d +%d,%d @@",
|
||||
math.max(1, i - 2), math.min(5, #orig_lines - i + 3),
|
||||
math.max(1, i - 2), math.min(5, #mod_lines - i + 3)))
|
||||
in_change = true
|
||||
end
|
||||
if orig ~= "" then
|
||||
table.insert(lines, "-" .. orig)
|
||||
end
|
||||
if mod ~= "" then
|
||||
table.insert(lines, "+" .. mod)
|
||||
end
|
||||
else
|
||||
if in_change then
|
||||
table.insert(lines, " " .. orig)
|
||||
in_change = false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Update the diff view for current entry
|
||||
local function update_diff_view()
|
||||
if not state.diff_buf or not vim.api.nvim_buf_is_valid(state.diff_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local entry = state.entries[state.current_index]
|
||||
local ui_prompts = prompts.review
|
||||
if not entry then
|
||||
vim.bo[state.diff_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, { ui_prompts.messages.no_changes_short })
|
||||
vim.bo[state.diff_buf].modifiable = false
|
||||
return
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
|
||||
-- Header
|
||||
local status_icon = entry.applied and " " or (entry.approved and " " or " ")
|
||||
local op_icon = entry.operation == "create" and "+" or (entry.operation == "delete" and "-" or "~")
|
||||
local current_status = entry.applied and ui_prompts.status.applied
|
||||
or (entry.approved and ui_prompts.status.approved or ui_prompts.status.pending)
|
||||
|
||||
table.insert(lines, string.format(ui_prompts.diff_header.top,
|
||||
status_icon, op_icon, vim.fn.fnamemodify(entry.path, ":t")))
|
||||
table.insert(lines, string.format(ui_prompts.diff_header.path, entry.path))
|
||||
table.insert(lines, string.format(ui_prompts.diff_header.op, entry.operation))
|
||||
table.insert(lines, string.format(ui_prompts.diff_header.status, current_status))
|
||||
table.insert(lines, ui_prompts.diff_header.bottom)
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Diff content
|
||||
local diff_lines = generate_diff_lines(entry.original, entry.modified, entry.path)
|
||||
for _, line in ipairs(diff_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
vim.bo[state.diff_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, lines)
|
||||
vim.bo[state.diff_buf].modifiable = false
|
||||
vim.bo[state.diff_buf].filetype = "diff"
|
||||
end
|
||||
|
||||
--- Update the file list
|
||||
local function update_file_list()
|
||||
if not state.list_buf or not vim.api.nvim_buf_is_valid(state.list_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
local ui_prompts = prompts.review
|
||||
local lines = {}
|
||||
table.insert(lines, string.format(ui_prompts.list_menu.top, #state.entries))
|
||||
for _, item in ipairs(ui_prompts.list_menu.items) do
|
||||
table.insert(lines, item)
|
||||
end
|
||||
table.insert(lines, ui_prompts.list_menu.bottom)
|
||||
table.insert(lines, "")
|
||||
|
||||
for i, entry in ipairs(state.entries) do
|
||||
local prefix = (i == state.current_index) and "▶ " or " "
|
||||
local status = entry.applied and "" or (entry.approved and "" or "○")
|
||||
local op = entry.operation == "create" and "[+]" or (entry.operation == "delete" and "[-]" or "[~]")
|
||||
local filename = vim.fn.fnamemodify(entry.path, ":t")
|
||||
|
||||
table.insert(lines, string.format("%s%s %s %s", prefix, status, op, filename))
|
||||
end
|
||||
|
||||
if #state.entries == 0 then
|
||||
table.insert(lines, ui_prompts.messages.no_changes)
|
||||
end
|
||||
|
||||
vim.bo[state.list_buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.list_buf, 0, -1, false, lines)
|
||||
vim.bo[state.list_buf].modifiable = false
|
||||
|
||||
-- Highlight current line
|
||||
if state.list_win and vim.api.nvim_win_is_valid(state.list_win) then
|
||||
local target_line = 9 + state.current_index - 1
|
||||
if target_line <= vim.api.nvim_buf_line_count(state.list_buf) then
|
||||
vim.api.nvim_win_set_cursor(state.list_win, { target_line, 0 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Navigate to next entry
|
||||
function M.next()
|
||||
if state.current_index < #state.entries then
|
||||
state.current_index = state.current_index + 1
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
end
|
||||
end
|
||||
|
||||
--- Navigate to previous entry
|
||||
function M.prev()
|
||||
if state.current_index > 1 then
|
||||
state.current_index = state.current_index - 1
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
end
|
||||
end
|
||||
|
||||
--- Approve current entry
|
||||
function M.approve_current()
|
||||
local entry = state.entries[state.current_index]
|
||||
if entry and not entry.applied then
|
||||
entry.approved = true
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
end
|
||||
end
|
||||
|
||||
--- Reject current entry
|
||||
function M.reject_current()
|
||||
local entry = state.entries[state.current_index]
|
||||
if entry and not entry.applied then
|
||||
entry.approved = false
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
end
|
||||
end
|
||||
|
||||
--- Approve all entries
|
||||
function M.approve_all()
|
||||
for _, entry in ipairs(state.entries) do
|
||||
if not entry.applied then
|
||||
entry.approved = true
|
||||
end
|
||||
end
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
end
|
||||
|
||||
--- Apply approved changes
|
||||
function M.apply_approved()
|
||||
local applied_count = 0
|
||||
|
||||
for _, entry in ipairs(state.entries) do
|
||||
if entry.approved and not entry.applied then
|
||||
if entry.operation == "create" or entry.operation == "edit" then
|
||||
local ok = utils.write_file(entry.path, entry.modified)
|
||||
if ok then
|
||||
entry.applied = true
|
||||
applied_count = applied_count + 1
|
||||
end
|
||||
elseif entry.operation == "delete" then
|
||||
local ok = os.remove(entry.path)
|
||||
if ok then
|
||||
entry.applied = true
|
||||
applied_count = applied_count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
|
||||
if applied_count > 0 then
|
||||
utils.notify(string.format(prompts.review.messages.applied_count, applied_count))
|
||||
end
|
||||
|
||||
return applied_count
|
||||
end
|
||||
|
||||
--- Open the diff review UI
|
||||
function M.open()
|
||||
if state.is_open then
|
||||
return
|
||||
end
|
||||
|
||||
if #state.entries == 0 then
|
||||
utils.notify(prompts.review.messages.no_changes_short, vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
|
||||
-- Create list buffer
|
||||
state.list_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.list_buf].buftype = "nofile"
|
||||
vim.bo[state.list_buf].bufhidden = "wipe"
|
||||
vim.bo[state.list_buf].swapfile = false
|
||||
|
||||
-- Create diff buffer
|
||||
state.diff_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.diff_buf].buftype = "nofile"
|
||||
vim.bo[state.diff_buf].bufhidden = "wipe"
|
||||
vim.bo[state.diff_buf].swapfile = false
|
||||
|
||||
-- Create layout: list on left (30 cols), diff on right
|
||||
vim.cmd("tabnew")
|
||||
state.diff_win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.diff_win, state.diff_buf)
|
||||
|
||||
vim.cmd("topleft vsplit")
|
||||
state.list_win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.list_win, state.list_buf)
|
||||
vim.api.nvim_win_set_width(state.list_win, 35)
|
||||
|
||||
-- Window options
|
||||
for _, win in ipairs({ state.list_win, state.diff_win }) do
|
||||
vim.wo[win].number = false
|
||||
vim.wo[win].relativenumber = false
|
||||
vim.wo[win].signcolumn = "no"
|
||||
vim.wo[win].wrap = false
|
||||
vim.wo[win].cursorline = true
|
||||
end
|
||||
|
||||
-- Set up keymaps for list buffer
|
||||
local list_opts = { buffer = state.list_buf, noremap = true, silent = true }
|
||||
vim.keymap.set("n", "j", M.next, list_opts)
|
||||
vim.keymap.set("n", "k", M.prev, list_opts)
|
||||
vim.keymap.set("n", "<Down>", M.next, list_opts)
|
||||
vim.keymap.set("n", "<Up>", M.prev, list_opts)
|
||||
vim.keymap.set("n", "<CR>", function() vim.api.nvim_set_current_win(state.diff_win) end, list_opts)
|
||||
vim.keymap.set("n", "a", M.approve_current, list_opts)
|
||||
vim.keymap.set("n", "r", M.reject_current, list_opts)
|
||||
vim.keymap.set("n", "A", M.approve_all, list_opts)
|
||||
vim.keymap.set("n", "q", M.close, list_opts)
|
||||
vim.keymap.set("n", "<Esc>", M.close, list_opts)
|
||||
|
||||
-- Set up keymaps for diff buffer
|
||||
local diff_opts = { buffer = state.diff_buf, noremap = true, silent = true }
|
||||
vim.keymap.set("n", "j", M.next, diff_opts)
|
||||
vim.keymap.set("n", "k", M.prev, diff_opts)
|
||||
vim.keymap.set("n", "<Tab>", function() vim.api.nvim_set_current_win(state.list_win) end, diff_opts)
|
||||
vim.keymap.set("n", "a", M.approve_current, diff_opts)
|
||||
vim.keymap.set("n", "r", M.reject_current, diff_opts)
|
||||
vim.keymap.set("n", "A", M.approve_all, diff_opts)
|
||||
vim.keymap.set("n", "q", M.close, diff_opts)
|
||||
vim.keymap.set("n", "<Esc>", M.close, diff_opts)
|
||||
|
||||
state.is_open = true
|
||||
state.current_index = 1
|
||||
|
||||
-- Initial render
|
||||
update_file_list()
|
||||
update_diff_view()
|
||||
|
||||
-- Focus list window
|
||||
vim.api.nvim_set_current_win(state.list_win)
|
||||
end
|
||||
|
||||
--- Close the diff review UI
|
||||
function M.close()
|
||||
if not state.is_open then
|
||||
return
|
||||
end
|
||||
|
||||
-- Close the tab (which closes both windows)
|
||||
pcall(vim.cmd, "tabclose")
|
||||
|
||||
state.list_buf = nil
|
||||
state.list_win = nil
|
||||
state.diff_buf = nil
|
||||
state.diff_win = nil
|
||||
state.is_open = false
|
||||
end
|
||||
|
||||
--- Check if review UI is open
|
||||
---@return boolean
|
||||
function M.is_open()
|
||||
return state.is_open
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,380 +0,0 @@
|
||||
---@mod codetyper.agent.logs Real-time logging for agent operations
|
||||
---
|
||||
--- Captures and displays the agent's thinking process, token usage, and LLM info.
|
||||
|
||||
local M = {}
|
||||
|
||||
local params = require("codetyper.params.agents.logs")
|
||||
|
||||
|
||||
---@class LogEntry
|
||||
---@field timestamp string ISO timestamp
|
||||
---@field level string "info" | "debug" | "request" | "response" | "tool" | "error"
|
||||
---@field message string Log message
|
||||
---@field data? table Optional structured data
|
||||
|
||||
---@class LogState
|
||||
---@field entries LogEntry[] All log entries
|
||||
---@field listeners table[] Functions to call when new entries are added
|
||||
---@field total_prompt_tokens number Running total of prompt tokens
|
||||
---@field total_response_tokens number Running total of response tokens
|
||||
|
||||
local state = {
|
||||
entries = {},
|
||||
listeners = {},
|
||||
total_prompt_tokens = 0,
|
||||
total_response_tokens = 0,
|
||||
current_provider = nil,
|
||||
current_model = nil,
|
||||
}
|
||||
|
||||
--- Get current timestamp
|
||||
---@return string
|
||||
local function get_timestamp()
|
||||
return os.date("%H:%M:%S")
|
||||
end
|
||||
|
||||
--- Add a log entry
|
||||
---@param level string Log level
|
||||
---@param message string Log message
|
||||
---@param data? table Optional data
|
||||
function M.log(level, message, data)
|
||||
local entry = {
|
||||
timestamp = get_timestamp(),
|
||||
level = level,
|
||||
message = message,
|
||||
data = data,
|
||||
}
|
||||
|
||||
table.insert(state.entries, entry)
|
||||
|
||||
-- Notify all listeners
|
||||
for _, listener in ipairs(state.listeners) do
|
||||
pcall(listener, entry)
|
||||
end
|
||||
end
|
||||
|
||||
--- Log info message
|
||||
---@param message string
|
||||
---@param data? table
|
||||
function M.info(message, data)
|
||||
M.log("info", message, data)
|
||||
end
|
||||
|
||||
--- Log debug message
|
||||
---@param message string
|
||||
---@param data? table
|
||||
function M.debug(message, data)
|
||||
M.log("debug", message, data)
|
||||
end
|
||||
|
||||
--- Log API request
|
||||
---@param provider string LLM provider
|
||||
---@param model string Model name
|
||||
---@param prompt_tokens? number Estimated prompt tokens
|
||||
function M.request(provider, model, prompt_tokens)
|
||||
state.current_provider = provider
|
||||
state.current_model = model
|
||||
|
||||
local msg = string.format("[%s] %s", provider:upper(), model)
|
||||
if prompt_tokens then
|
||||
msg = msg .. string.format(" | Prompt: ~%d tokens", prompt_tokens)
|
||||
end
|
||||
|
||||
M.log("request", msg, {
|
||||
provider = provider,
|
||||
model = model,
|
||||
prompt_tokens = prompt_tokens,
|
||||
})
|
||||
end
|
||||
|
||||
--- Log API response with token usage
|
||||
---@param prompt_tokens number Tokens used in prompt
|
||||
---@param response_tokens number Tokens in response
|
||||
---@param stop_reason? string Why the response stopped
|
||||
function M.response(prompt_tokens, response_tokens, stop_reason)
|
||||
state.total_prompt_tokens = state.total_prompt_tokens + prompt_tokens
|
||||
state.total_response_tokens = state.total_response_tokens + response_tokens
|
||||
|
||||
local msg = string.format(
|
||||
"Tokens: %d in / %d out | Total: %d in / %d out",
|
||||
prompt_tokens,
|
||||
response_tokens,
|
||||
state.total_prompt_tokens,
|
||||
state.total_response_tokens
|
||||
)
|
||||
|
||||
if stop_reason then
|
||||
msg = msg .. " | Stop: " .. stop_reason
|
||||
end
|
||||
|
||||
M.log("response", msg, {
|
||||
prompt_tokens = prompt_tokens,
|
||||
response_tokens = response_tokens,
|
||||
total_prompt = state.total_prompt_tokens,
|
||||
total_response = state.total_response_tokens,
|
||||
stop_reason = stop_reason,
|
||||
})
|
||||
end
|
||||
|
||||
--- Log tool execution
|
||||
---@param tool_name string Name of the tool
|
||||
---@param status string "start" | "success" | "error" | "approval"
|
||||
---@param details? string Additional details
|
||||
function M.tool(tool_name, status, details)
|
||||
local icons = params.icons
|
||||
|
||||
local msg = string.format("[%s] %s", icons[status] or status, tool_name)
|
||||
if details then
|
||||
msg = msg .. ": " .. details
|
||||
end
|
||||
|
||||
M.log("tool", msg, {
|
||||
tool = tool_name,
|
||||
status = status,
|
||||
details = details,
|
||||
})
|
||||
end
|
||||
|
||||
--- Log error
|
||||
---@param message string
|
||||
---@param data? table
|
||||
function M.error(message, data)
|
||||
M.log("error", "ERROR: " .. message, data)
|
||||
end
|
||||
|
||||
--- Log warning
|
||||
---@param message string
|
||||
---@param data? table
|
||||
function M.warning(message, data)
|
||||
M.log("warning", "WARN: " .. message, data)
|
||||
end
|
||||
|
||||
--- Add log entry (compatibility function for scheduler)
|
||||
--- Accepts {type = "info", message = "..."} format
|
||||
---@param entry table Log entry with type and message
|
||||
function M.add(entry)
|
||||
if entry.type == "clear" then
|
||||
M.clear()
|
||||
return
|
||||
end
|
||||
M.log(entry.type or "info", entry.message or "", entry.data)
|
||||
end
|
||||
|
||||
--- Log thinking/reasoning step (Claude Code style)
|
||||
---@param step string Description of what's happening
|
||||
function M.thinking(step)
|
||||
M.log("thinking", step)
|
||||
end
|
||||
|
||||
--- Log a reasoning/explanation message (shown prominently)
|
||||
---@param message string The reasoning message
|
||||
function M.reason(message)
|
||||
M.log("reason", message)
|
||||
end
|
||||
|
||||
--- Log file read operation
|
||||
---@param filepath string Path of file being read
|
||||
---@param lines? number Number of lines read
|
||||
function M.read(filepath, lines)
|
||||
local msg = string.format("Read(%s)", vim.fn.fnamemodify(filepath, ":~:."))
|
||||
if lines then
|
||||
msg = msg .. string.format("\n ⎿ Read %d lines", lines)
|
||||
end
|
||||
M.log("action", msg)
|
||||
end
|
||||
|
||||
--- Log explore/search operation
|
||||
---@param description string What we're exploring
|
||||
function M.explore(description)
|
||||
M.log("action", string.format("Explore(%s)", description))
|
||||
end
|
||||
|
||||
--- Log explore done
|
||||
---@param tool_uses number Number of tool uses
|
||||
---@param tokens number Tokens used
|
||||
---@param duration number Duration in seconds
|
||||
function M.explore_done(tool_uses, tokens, duration)
|
||||
M.log("result", string.format(" ⎿ Done (%d tool uses · %.1fk tokens · %.1fs)", tool_uses, tokens / 1000, duration))
|
||||
end
|
||||
|
||||
--- Log update/edit operation
|
||||
---@param filepath string Path of file being edited
|
||||
---@param added? number Lines added
|
||||
---@param removed? number Lines removed
|
||||
function M.update(filepath, added, removed)
|
||||
local msg = string.format("Update(%s)", vim.fn.fnamemodify(filepath, ":~:."))
|
||||
if added or removed then
|
||||
local parts = {}
|
||||
if added and added > 0 then
|
||||
table.insert(parts, string.format("Added %d lines", added))
|
||||
end
|
||||
if removed and removed > 0 then
|
||||
table.insert(parts, string.format("Removed %d lines", removed))
|
||||
end
|
||||
if #parts > 0 then
|
||||
msg = msg .. "\n ⎿ " .. table.concat(parts, ", ")
|
||||
end
|
||||
end
|
||||
M.log("action", msg)
|
||||
end
|
||||
|
||||
--- Log a task/step that's in progress
|
||||
---@param task string Task name
|
||||
---@param status string Status message (optional)
|
||||
function M.task(task, status)
|
||||
local msg = task
|
||||
if status then
|
||||
msg = msg .. " " .. status
|
||||
end
|
||||
M.log("task", msg)
|
||||
end
|
||||
|
||||
--- Log task completion
|
||||
---@param next_task? string Next task (optional)
|
||||
function M.task_done(next_task)
|
||||
local msg = " ⎿ Done"
|
||||
if next_task then
|
||||
msg = msg .. "\n✶ " .. next_task
|
||||
end
|
||||
M.log("result", msg)
|
||||
end
|
||||
|
||||
--- Register a listener for new log entries
|
||||
---@param callback fun(entry: LogEntry)
|
||||
---@return number Listener ID for removal
|
||||
function M.add_listener(callback)
|
||||
table.insert(state.listeners, callback)
|
||||
return #state.listeners
|
||||
end
|
||||
|
||||
--- Remove a listener
|
||||
---@param id number Listener ID
|
||||
function M.remove_listener(id)
|
||||
if id > 0 and id <= #state.listeners then
|
||||
table.remove(state.listeners, id)
|
||||
end
|
||||
end
|
||||
|
||||
--- Get all log entries
|
||||
---@return LogEntry[]
|
||||
function M.get_entries()
|
||||
return state.entries
|
||||
end
|
||||
|
||||
--- Get token totals
|
||||
---@return number, number prompt_tokens, response_tokens
|
||||
function M.get_token_totals()
|
||||
return state.total_prompt_tokens, state.total_response_tokens
|
||||
end
|
||||
|
||||
--- Get current provider info
|
||||
---@return string?, string? provider, model
|
||||
function M.get_provider_info()
|
||||
return state.current_provider, state.current_model
|
||||
end
|
||||
|
||||
--- Clear all logs and reset counters
|
||||
function M.clear()
|
||||
state.entries = {}
|
||||
state.total_prompt_tokens = 0
|
||||
state.total_response_tokens = 0
|
||||
state.current_provider = nil
|
||||
state.current_model = nil
|
||||
|
||||
-- Notify listeners of clear
|
||||
for _, listener in ipairs(state.listeners) do
|
||||
pcall(listener, { level = "clear" })
|
||||
end
|
||||
end
|
||||
|
||||
--- Format entry for display
|
||||
---@param entry LogEntry
|
||||
---@return string
|
||||
function M.format_entry(entry)
|
||||
-- Claude Code style formatting for thinking/action entries
|
||||
local thinking_types = params.thinking_types
|
||||
local is_thinking = vim.tbl_contains(thinking_types, entry.level)
|
||||
|
||||
if is_thinking then
|
||||
local prefix = params.thinking_prefixes[entry.level] or "⏺"
|
||||
|
||||
if prefix ~= "" then
|
||||
return prefix .. " " .. entry.message
|
||||
else
|
||||
return entry.message
|
||||
end
|
||||
end
|
||||
|
||||
-- Traditional log format for other types
|
||||
local level_prefix = params.level_icons[entry.level] or "?"
|
||||
|
||||
local base = string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message)
|
||||
|
||||
-- If this is a response entry with raw_response, append the full response
|
||||
if entry.data and entry.data.raw_response then
|
||||
local response = entry.data.raw_response
|
||||
-- Add separator and the full response
|
||||
base = base .. "\n" .. string.rep("-", 40) .. "\n" .. response .. "\n" .. string.rep("-", 40)
|
||||
end
|
||||
|
||||
return base
|
||||
end
|
||||
|
||||
--- Format entry for display in chat (compact Claude Code style)
|
||||
---@param entry LogEntry
|
||||
---@return string|nil Formatted string or nil to skip
|
||||
function M.format_for_chat(entry)
|
||||
-- Skip certain log types in chat view
|
||||
local skip_types = { "debug", "queue", "patch" }
|
||||
if vim.tbl_contains(skip_types, entry.level) then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Claude Code style formatting
|
||||
local thinking_types = params.thinking_types
|
||||
if vim.tbl_contains(thinking_types, entry.level) then
|
||||
local prefix = params.thinking_prefixes[entry.level] or "⏺"
|
||||
|
||||
if prefix ~= "" then
|
||||
return prefix .. " " .. entry.message
|
||||
else
|
||||
return entry.message
|
||||
end
|
||||
end
|
||||
|
||||
-- Tool logs
|
||||
if entry.level == "tool" then
|
||||
return "⏺ " .. entry.message:gsub("^%[.-%] ", "")
|
||||
end
|
||||
|
||||
-- Info/success
|
||||
if entry.level == "info" or entry.level == "success" then
|
||||
return "⏺ " .. entry.message
|
||||
end
|
||||
|
||||
-- Errors
|
||||
if entry.level == "error" then
|
||||
return "⚠ " .. entry.message
|
||||
end
|
||||
|
||||
-- Request/response (compact)
|
||||
if entry.level == "request" then
|
||||
return "⏺ " .. entry.message
|
||||
end
|
||||
if entry.level == "response" then
|
||||
return " ⎿ " .. entry.message
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Estimate token count for a string (rough approximation)
|
||||
---@param text string
|
||||
---@return number
|
||||
function M.estimate_tokens(text)
|
||||
-- Rough estimate: ~4 characters per token for English text
|
||||
return math.ceil(#text / 4)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,382 +0,0 @@
|
||||
---@mod codetyper.logs_panel Standalone logs panel for code generation
|
||||
---
|
||||
--- Shows real-time logs when generating code via /@ @/ prompts.
|
||||
|
||||
local M = {}
|
||||
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local queue = require("codetyper.core.events.queue")
|
||||
|
||||
---@class LogsPanelState
|
||||
---@field buf number|nil Logs buffer
|
||||
---@field win number|nil Logs window
|
||||
---@field queue_buf number|nil Queue buffer
|
||||
---@field queue_win number|nil Queue window
|
||||
---@field is_open boolean Whether the panel is open
|
||||
---@field listener_id number|nil Listener ID for logs
|
||||
---@field queue_listener_id number|nil Listener ID for queue
|
||||
|
||||
local state = {
|
||||
buf = nil,
|
||||
win = nil,
|
||||
queue_buf = nil,
|
||||
queue_win = nil,
|
||||
is_open = false,
|
||||
listener_id = nil,
|
||||
queue_listener_id = nil,
|
||||
}
|
||||
|
||||
--- Namespace for highlights
|
||||
local ns_logs = vim.api.nvim_create_namespace("codetyper_logs_panel")
|
||||
local ns_queue = vim.api.nvim_create_namespace("codetyper_queue_panel")
|
||||
|
||||
--- Fixed dimensions
|
||||
local LOGS_WIDTH = 60
|
||||
local QUEUE_HEIGHT = 8
|
||||
|
||||
--- Add a log entry to the buffer
|
||||
---@param entry table Log entry
|
||||
local function add_log_entry(entry)
|
||||
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Handle clear event
|
||||
if entry.level == "clear" then
|
||||
vim.bo[state.buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
|
||||
"Generation Logs",
|
||||
string.rep("─", LOGS_WIDTH - 2),
|
||||
"",
|
||||
})
|
||||
vim.bo[state.buf].modifiable = false
|
||||
return
|
||||
end
|
||||
|
||||
vim.bo[state.buf].modifiable = true
|
||||
|
||||
local formatted = logs.format_entry(entry)
|
||||
local formatted_lines = vim.split(formatted, "\n", { plain = true })
|
||||
local line_count = vim.api.nvim_buf_line_count(state.buf)
|
||||
|
||||
vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, formatted_lines)
|
||||
|
||||
-- Apply highlighting based on level
|
||||
local hl_map = {
|
||||
info = "DiagnosticInfo",
|
||||
debug = "Comment",
|
||||
request = "DiagnosticWarn",
|
||||
response = "DiagnosticOk",
|
||||
tool = "DiagnosticHint",
|
||||
error = "DiagnosticError",
|
||||
}
|
||||
|
||||
local hl = hl_map[entry.level] or "Normal"
|
||||
for i = 0, #formatted_lines - 1 do
|
||||
vim.api.nvim_buf_add_highlight(state.buf, ns_logs, hl, line_count + i, 0, -1)
|
||||
end
|
||||
|
||||
vim.bo[state.buf].modifiable = false
|
||||
|
||||
-- Auto-scroll logs
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
local new_count = vim.api.nvim_buf_line_count(state.buf)
|
||||
pcall(vim.api.nvim_win_set_cursor, state.win, { new_count, 0 })
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Update the title with token counts
|
||||
local function update_title()
|
||||
if not state.win or not vim.api.nvim_win_is_valid(state.win) then
|
||||
return
|
||||
end
|
||||
|
||||
local prompt_tokens, response_tokens = logs.get_token_totals()
|
||||
local provider, model = logs.get_provider_info()
|
||||
|
||||
if provider and state.buf and vim.api.nvim_buf_is_valid(state.buf) then
|
||||
vim.bo[state.buf].modifiable = true
|
||||
local title = string.format("%s | %d/%d tokens", (provider or ""):upper(), prompt_tokens, response_tokens)
|
||||
vim.api.nvim_buf_set_lines(state.buf, 0, 1, false, { title })
|
||||
vim.bo[state.buf].modifiable = false
|
||||
end
|
||||
end
|
||||
|
||||
--- Update the queue display
|
||||
local function update_queue_display()
|
||||
if not state.queue_buf or not vim.api.nvim_buf_is_valid(state.queue_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
if not state.queue_buf or not vim.api.nvim_buf_is_valid(state.queue_buf) then
|
||||
return
|
||||
end
|
||||
|
||||
vim.bo[state.queue_buf].modifiable = true
|
||||
|
||||
local lines = {
|
||||
"Queue",
|
||||
string.rep("─", LOGS_WIDTH - 2),
|
||||
}
|
||||
|
||||
-- Get all events (pending and processing)
|
||||
local pending = queue.get_pending()
|
||||
local processing = queue.get_processing()
|
||||
|
||||
-- Add processing events first
|
||||
for _, event in ipairs(processing) do
|
||||
local filename = vim.fn.fnamemodify(event.target_path or "", ":t")
|
||||
local line_num = event.range and event.range.start_line or 0
|
||||
local prompt_preview = (event.prompt_content or ""):sub(1, 25):gsub("\n", " ")
|
||||
if #(event.prompt_content or "") > 25 then
|
||||
prompt_preview = prompt_preview .. "..."
|
||||
end
|
||||
table.insert(lines, string.format("▶ %s:%d %s", filename, line_num, prompt_preview))
|
||||
end
|
||||
|
||||
-- Add pending events
|
||||
for _, event in ipairs(pending) do
|
||||
local filename = vim.fn.fnamemodify(event.target_path or "", ":t")
|
||||
local line_num = event.range and event.range.start_line or 0
|
||||
local prompt_preview = (event.prompt_content or ""):sub(1, 25):gsub("\n", " ")
|
||||
if #(event.prompt_content or "") > 25 then
|
||||
prompt_preview = prompt_preview .. "..."
|
||||
end
|
||||
table.insert(lines, string.format("○ %s:%d %s", filename, line_num, prompt_preview))
|
||||
end
|
||||
|
||||
if #pending == 0 and #processing == 0 then
|
||||
table.insert(lines, " (empty)")
|
||||
end
|
||||
|
||||
vim.api.nvim_buf_set_lines(state.queue_buf, 0, -1, false, lines)
|
||||
|
||||
-- Apply highlights
|
||||
vim.api.nvim_buf_clear_namespace(state.queue_buf, ns_queue, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Title", 0, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Comment", 1, 0, -1)
|
||||
|
||||
local line_idx = 2
|
||||
for _ = 1, #processing do
|
||||
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "DiagnosticWarn", line_idx, 0, 1)
|
||||
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "String", line_idx, 2, -1)
|
||||
line_idx = line_idx + 1
|
||||
end
|
||||
for _ = 1, #pending do
|
||||
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Comment", line_idx, 0, 1)
|
||||
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Normal", line_idx, 2, -1)
|
||||
line_idx = line_idx + 1
|
||||
end
|
||||
|
||||
vim.bo[state.queue_buf].modifiable = false
|
||||
end)
|
||||
end
|
||||
|
||||
--- Open the logs panel
|
||||
function M.open()
|
||||
if state.is_open then
|
||||
return
|
||||
end
|
||||
|
||||
-- Clear previous logs
|
||||
logs.clear()
|
||||
|
||||
-- Create logs buffer
|
||||
state.buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.buf].buftype = "nofile"
|
||||
vim.bo[state.buf].bufhidden = "hide"
|
||||
vim.bo[state.buf].swapfile = false
|
||||
|
||||
-- Create window on the right
|
||||
vim.cmd("botright vsplit")
|
||||
state.win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.win, state.buf)
|
||||
vim.api.nvim_win_set_width(state.win, LOGS_WIDTH)
|
||||
|
||||
-- Window options for logs
|
||||
vim.wo[state.win].number = false
|
||||
vim.wo[state.win].relativenumber = false
|
||||
vim.wo[state.win].signcolumn = "no"
|
||||
vim.wo[state.win].wrap = true
|
||||
vim.wo[state.win].linebreak = true
|
||||
vim.wo[state.win].winfixwidth = true
|
||||
vim.wo[state.win].cursorline = false
|
||||
|
||||
-- Set initial content for logs
|
||||
vim.bo[state.buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
|
||||
"Generation Logs",
|
||||
string.rep("─", LOGS_WIDTH - 2),
|
||||
"",
|
||||
})
|
||||
vim.bo[state.buf].modifiable = false
|
||||
|
||||
-- Create queue buffer
|
||||
state.queue_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[state.queue_buf].buftype = "nofile"
|
||||
vim.bo[state.queue_buf].bufhidden = "hide"
|
||||
vim.bo[state.queue_buf].swapfile = false
|
||||
|
||||
-- Create queue window as horizontal split at bottom of logs window
|
||||
vim.cmd("belowright split")
|
||||
state.queue_win = vim.api.nvim_get_current_win()
|
||||
vim.api.nvim_win_set_buf(state.queue_win, state.queue_buf)
|
||||
vim.api.nvim_win_set_height(state.queue_win, QUEUE_HEIGHT)
|
||||
|
||||
-- Window options for queue
|
||||
vim.wo[state.queue_win].number = false
|
||||
vim.wo[state.queue_win].relativenumber = false
|
||||
vim.wo[state.queue_win].signcolumn = "no"
|
||||
vim.wo[state.queue_win].wrap = true
|
||||
vim.wo[state.queue_win].linebreak = true
|
||||
vim.wo[state.queue_win].winfixheight = true
|
||||
vim.wo[state.queue_win].cursorline = false
|
||||
|
||||
-- Setup keymaps for logs buffer
|
||||
local opts = { buffer = state.buf, noremap = true, silent = true }
|
||||
vim.keymap.set("n", "q", M.close, opts)
|
||||
vim.keymap.set("n", "<Esc>", M.close, opts)
|
||||
|
||||
-- Setup keymaps for queue buffer
|
||||
local queue_opts = { buffer = state.queue_buf, noremap = true, silent = true }
|
||||
vim.keymap.set("n", "q", M.close, queue_opts)
|
||||
vim.keymap.set("n", "<Esc>", M.close, queue_opts)
|
||||
|
||||
-- Register log listener
|
||||
state.listener_id = logs.add_listener(function(entry)
|
||||
add_log_entry(entry)
|
||||
if entry.level == "response" then
|
||||
vim.schedule(update_title)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Register queue listener
|
||||
state.queue_listener_id = queue.add_listener(function()
|
||||
update_queue_display()
|
||||
end)
|
||||
|
||||
-- Initial queue display
|
||||
update_queue_display()
|
||||
|
||||
state.is_open = true
|
||||
|
||||
-- Return focus to previous window
|
||||
vim.cmd("wincmd p")
|
||||
|
||||
logs.info("Logs panel opened")
|
||||
end
|
||||
|
||||
--- Close the logs panel
|
||||
---@param force? boolean Force close even if not marked as open
|
||||
function M.close(force)
|
||||
if not state.is_open and not force then
|
||||
return
|
||||
end
|
||||
|
||||
-- Remove log listener
|
||||
if state.listener_id then
|
||||
pcall(logs.remove_listener, state.listener_id)
|
||||
state.listener_id = nil
|
||||
end
|
||||
|
||||
-- Remove queue listener
|
||||
if state.queue_listener_id then
|
||||
pcall(queue.remove_listener, state.queue_listener_id)
|
||||
state.queue_listener_id = nil
|
||||
end
|
||||
|
||||
-- Close queue window first
|
||||
if state.queue_win then
|
||||
pcall(vim.api.nvim_win_close, state.queue_win, true)
|
||||
state.queue_win = nil
|
||||
end
|
||||
|
||||
-- Close logs window
|
||||
if state.win then
|
||||
pcall(vim.api.nvim_win_close, state.win, true)
|
||||
state.win = nil
|
||||
end
|
||||
|
||||
-- Delete queue buffer
|
||||
if state.queue_buf then
|
||||
pcall(vim.api.nvim_buf_delete, state.queue_buf, { force = true })
|
||||
state.queue_buf = nil
|
||||
end
|
||||
|
||||
-- Delete logs buffer
|
||||
if state.buf then
|
||||
pcall(vim.api.nvim_buf_delete, state.buf, { force = true })
|
||||
state.buf = nil
|
||||
end
|
||||
|
||||
state.is_open = false
|
||||
end
|
||||
|
||||
--- Toggle the logs panel
|
||||
function M.toggle()
|
||||
if state.is_open then
|
||||
M.close()
|
||||
else
|
||||
M.open()
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if panel is open
|
||||
---@return boolean
|
||||
function M.is_open()
|
||||
return state.is_open
|
||||
end
|
||||
|
||||
--- Ensure panel is open (call before starting generation)
|
||||
function M.ensure_open()
|
||||
if not state.is_open then
|
||||
M.open()
|
||||
end
|
||||
end
|
||||
|
||||
--- Setup autocmds for the logs panel
|
||||
function M.setup()
|
||||
local group = vim.api.nvim_create_augroup("CodetypeLogsPanel", { clear = true })
|
||||
|
||||
-- Close logs panel when exiting Neovim
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
-- Force close to ensure cleanup even in edge cases
|
||||
M.close(true)
|
||||
end,
|
||||
desc = "Close logs panel before exiting Neovim",
|
||||
})
|
||||
|
||||
-- Also clean up when QuitPre fires (handles :qa, :wqa, etc.)
|
||||
vim.api.nvim_create_autocmd("QuitPre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
-- Check if this is the last window (about to quit Neovim)
|
||||
local wins = vim.api.nvim_list_wins()
|
||||
local real_wins = 0
|
||||
for _, win in ipairs(wins) do
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
local buftype = vim.bo[buf].buftype
|
||||
-- Count non-special windows
|
||||
if buftype == "" or buftype == "help" then
|
||||
real_wins = real_wins + 1
|
||||
end
|
||||
end
|
||||
-- If only logs/queue windows remain, close them
|
||||
if real_wins <= 1 then
|
||||
M.close(true)
|
||||
end
|
||||
end,
|
||||
desc = "Close logs panel on quit",
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,44 +0,0 @@
|
||||
---@mod codetyper.chat_switcher Modal picker to switch between Ask and Agent modes
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Show modal to switch between chat modes
|
||||
function M.show()
|
||||
local items = {
|
||||
{ label = "Ask", desc = "Q&A mode - ask questions about code", mode = "ask" },
|
||||
{ label = "Agent", desc = "Agent mode - can read/edit files", mode = "agent" },
|
||||
}
|
||||
|
||||
vim.ui.select(items, {
|
||||
prompt = "Select Chat Mode:",
|
||||
format_item = function(item)
|
||||
return item.label .. " - " .. item.desc
|
||||
end,
|
||||
}, function(choice)
|
||||
if not choice then
|
||||
return
|
||||
end
|
||||
|
||||
-- Close current panel first
|
||||
local ask = require("codetyper.features.ask.engine")
|
||||
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
|
||||
|
||||
if ask.is_open() then
|
||||
ask.close()
|
||||
end
|
||||
if agent_ui.is_open() then
|
||||
agent_ui.close()
|
||||
end
|
||||
|
||||
-- Open selected mode
|
||||
vim.schedule(function()
|
||||
if choice.mode == "ask" then
|
||||
ask.open()
|
||||
elseif choice.mode == "agent" then
|
||||
agent_ui.open()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,18 +0,0 @@
|
||||
--- Banned commands for safety
|
||||
M.BANNED_COMMANDS = {
|
||||
"rm -rf /",
|
||||
"rm -rf /*",
|
||||
"dd if=/dev/zero",
|
||||
"mkfs",
|
||||
":(){ :|:& };:",
|
||||
"> /dev/sda",
|
||||
}
|
||||
|
||||
--- Banned patterns
|
||||
M.BANNED_PATTERNS = {
|
||||
"curl.*|.*sh",
|
||||
"wget.*|.*sh",
|
||||
"rm%s+%-rf%s+/",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -34,16 +34,7 @@ local defaults = {
|
||||
file_pattern = "*.coder.*",
|
||||
},
|
||||
auto_gitignore = true,
|
||||
auto_open_ask = true, -- Auto-open Ask panel on startup
|
||||
auto_index = false, -- Auto-create coder companion files on file open
|
||||
scheduler = {
|
||||
enabled = true, -- Enable event-driven scheduler
|
||||
ollama_scout = true, -- Use Ollama as fast local scout for first attempt
|
||||
escalation_threshold = 0.7, -- Below this confidence, escalate to remote LLM
|
||||
max_concurrent = 2, -- Maximum concurrent workers
|
||||
completion_delay_ms = 100, -- Wait after completion popup closes
|
||||
apply_delay_ms = 5000, -- Wait before removing tags and applying code (ms)
|
||||
},
|
||||
indexer = {
|
||||
enabled = true, -- Enable project indexing
|
||||
auto_index = true, -- Index files on save
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
||||
---@mod codetyper.agent.diff Diff preview UI for agent changes
|
||||
---
|
||||
--- Shows diff previews for file changes and bash command approvals.
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Show a diff preview for file changes
|
||||
---@param diff_data table { path: string, original: string, modified: string, operation: string }
|
||||
---@param callback fun(approved: boolean) Called with user decision
|
||||
function M.show_diff(diff_data, callback)
|
||||
local original_lines = vim.split(diff_data.original, "\n", { plain = true })
|
||||
local modified_lines
|
||||
|
||||
-- For delete operations, show a clear message
|
||||
if diff_data.operation == "delete" then
|
||||
modified_lines = {
|
||||
"",
|
||||
" FILE WILL BE DELETED",
|
||||
"",
|
||||
" Reason: " .. (diff_data.reason or "No reason provided"),
|
||||
"",
|
||||
}
|
||||
else
|
||||
modified_lines = vim.split(diff_data.modified, "\n", { plain = true })
|
||||
end
|
||||
|
||||
-- Calculate window dimensions
|
||||
local width = math.floor(vim.o.columns * 0.8)
|
||||
local height = math.floor(vim.o.lines * 0.7)
|
||||
local row = math.floor((vim.o.lines - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
|
||||
-- Create left buffer (original)
|
||||
local left_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, original_lines)
|
||||
vim.bo[left_buf].modifiable = false
|
||||
vim.bo[left_buf].bufhidden = "wipe"
|
||||
|
||||
-- Create right buffer (modified)
|
||||
local right_buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, modified_lines)
|
||||
vim.bo[right_buf].modifiable = false
|
||||
vim.bo[right_buf].bufhidden = "wipe"
|
||||
|
||||
-- Set filetype for syntax highlighting based on file extension
|
||||
local ext = vim.fn.fnamemodify(diff_data.path, ":e")
|
||||
if ext and ext ~= "" then
|
||||
vim.bo[left_buf].filetype = ext
|
||||
vim.bo[right_buf].filetype = ext
|
||||
end
|
||||
|
||||
-- Create left window (original)
|
||||
local half_width = math.floor((width - 1) / 2)
|
||||
local left_win = vim.api.nvim_open_win(left_buf, true, {
|
||||
relative = "editor",
|
||||
width = half_width,
|
||||
height = height - 2,
|
||||
row = row,
|
||||
col = col,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
title = " ORIGINAL ",
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
-- Create right window (modified)
|
||||
local right_win = vim.api.nvim_open_win(right_buf, false, {
|
||||
relative = "editor",
|
||||
width = half_width,
|
||||
height = height - 2,
|
||||
row = row,
|
||||
col = col + half_width + 1,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
title = diff_data.operation == "delete" and " ⚠️ DELETE " or (" MODIFIED [" .. diff_data.operation .. "] "),
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
-- Enable diff mode in both windows
|
||||
vim.api.nvim_win_call(left_win, function()
|
||||
vim.cmd("diffthis")
|
||||
end)
|
||||
vim.api.nvim_win_call(right_win, function()
|
||||
vim.cmd("diffthis")
|
||||
end)
|
||||
|
||||
-- Sync scrolling
|
||||
vim.wo[left_win].scrollbind = true
|
||||
vim.wo[right_win].scrollbind = true
|
||||
vim.wo[left_win].cursorbind = true
|
||||
vim.wo[right_win].cursorbind = true
|
||||
|
||||
-- Track if callback was already called
|
||||
local callback_called = false
|
||||
|
||||
-- Close function
|
||||
local function close_and_respond(approved)
|
||||
if callback_called then
|
||||
return
|
||||
end
|
||||
callback_called = true
|
||||
|
||||
-- Disable diff mode
|
||||
pcall(function()
|
||||
vim.api.nvim_win_call(left_win, function()
|
||||
vim.cmd("diffoff")
|
||||
end)
|
||||
end)
|
||||
pcall(function()
|
||||
vim.api.nvim_win_call(right_win, function()
|
||||
vim.cmd("diffoff")
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Close windows
|
||||
pcall(vim.api.nvim_win_close, left_win, true)
|
||||
pcall(vim.api.nvim_win_close, right_win, true)
|
||||
|
||||
-- Call callback
|
||||
vim.schedule(function()
|
||||
callback(approved)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Set up keymaps for both buffers
|
||||
local keymap_opts = { noremap = true, silent = true, nowait = true }
|
||||
|
||||
for _, buf in ipairs({ left_buf, right_buf }) do
|
||||
-- Approve
|
||||
vim.keymap.set("n", "y", function()
|
||||
close_and_respond(true)
|
||||
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
|
||||
vim.keymap.set("n", "<CR>", function()
|
||||
close_and_respond(true)
|
||||
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
|
||||
|
||||
-- Reject
|
||||
vim.keymap.set("n", "n", function()
|
||||
close_and_respond(false)
|
||||
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
|
||||
vim.keymap.set("n", "q", function()
|
||||
close_and_respond(false)
|
||||
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
|
||||
vim.keymap.set("n", "<Esc>", function()
|
||||
close_and_respond(false)
|
||||
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
|
||||
|
||||
-- Switch between windows
|
||||
vim.keymap.set("n", "<Tab>", function()
|
||||
local current = vim.api.nvim_get_current_win()
|
||||
if current == left_win then
|
||||
vim.api.nvim_set_current_win(right_win)
|
||||
else
|
||||
vim.api.nvim_set_current_win(left_win)
|
||||
end
|
||||
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
|
||||
end
|
||||
|
||||
-- Show help message
|
||||
local help_msg = require("codetyper.prompts.agents.diff").diff_help
|
||||
|
||||
-- Iterate to replace {path} variable
|
||||
local final_help = {}
|
||||
for _, item in ipairs(help_msg) do
|
||||
if item[1] == "{path}" then
|
||||
table.insert(final_help, { diff_data.path, item[2] })
|
||||
else
|
||||
table.insert(final_help, item)
|
||||
end
|
||||
end
|
||||
|
||||
vim.api.nvim_echo(final_help, false, {})
|
||||
end
|
||||
|
||||
---@alias BashApprovalResult {approved: boolean, permission_level: string|nil}
|
||||
|
||||
--- Show approval dialog for bash commands with permission levels
|
||||
---@param command string The bash command to approve
|
||||
---@param callback fun(result: BashApprovalResult) Called with user decision
|
||||
function M.show_bash_approval(command, callback)
|
||||
local permissions = require("codetyper.features.agents.permissions")
|
||||
|
||||
-- Check if command is auto-approved
|
||||
local perm_result = permissions.check_bash_permission(command)
|
||||
if perm_result.auto and perm_result.allowed then
|
||||
vim.schedule(function()
|
||||
callback({ approved = true, permission_level = "auto" })
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Create approval dialog with options
|
||||
local approval_prompts = require("codetyper.prompts.agents.diff").bash_approval
|
||||
local lines = {
|
||||
"",
|
||||
approval_prompts.title,
|
||||
approval_prompts.divider,
|
||||
"",
|
||||
approval_prompts.command_label,
|
||||
" $ " .. command,
|
||||
"",
|
||||
}
|
||||
|
||||
-- Add warning for dangerous commands
|
||||
if not perm_result.allowed and perm_result.reason ~= "Requires approval" then
|
||||
table.insert(lines, approval_prompts.warning_prefix .. perm_result.reason)
|
||||
table.insert(lines, "")
|
||||
end
|
||||
|
||||
table.insert(lines, approval_prompts.divider)
|
||||
table.insert(lines, "")
|
||||
for _, opt in ipairs(approval_prompts.options) do
|
||||
table.insert(lines, opt)
|
||||
end
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, approval_prompts.divider)
|
||||
table.insert(lines, approval_prompts.cancel_hint)
|
||||
table.insert(lines, "")
|
||||
|
||||
local width = math.max(65, #command + 15)
|
||||
local height = #lines
|
||||
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
vim.bo[buf].modifiable = false
|
||||
vim.bo[buf].bufhidden = "wipe"
|
||||
|
||||
local win = vim.api.nvim_open_win(buf, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = math.floor((vim.o.lines - height) / 2),
|
||||
col = math.floor((vim.o.columns - width) / 2),
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
title = " Approve Command? ",
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
-- Apply highlighting
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "Title", 1, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "String", 5, 0, -1)
|
||||
|
||||
-- Highlight options
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match("^%s+%[y%]") then
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticOk", i - 1, 0, -1)
|
||||
elseif line:match("^%s+%[s%]") then
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticInfo", i - 1, 0, -1)
|
||||
elseif line:match("^%s+%[a%]") then
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticHint", i - 1, 0, -1)
|
||||
elseif line:match("^%s+%[n%]") then
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticError", i - 1, 0, -1)
|
||||
elseif line:match("⚠️") then
|
||||
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticWarn", i - 1, 0, -1)
|
||||
end
|
||||
end
|
||||
|
||||
local callback_called = false
|
||||
|
||||
local function close_and_respond(approved, permission_level)
|
||||
if callback_called then
|
||||
return
|
||||
end
|
||||
callback_called = true
|
||||
|
||||
-- Grant permission if approved with session or list level
|
||||
if approved and permission_level then
|
||||
permissions.grant_permission(command, permission_level)
|
||||
end
|
||||
|
||||
pcall(vim.api.nvim_win_close, win, true)
|
||||
|
||||
vim.schedule(function()
|
||||
callback({ approved = approved, permission_level = permission_level })
|
||||
end)
|
||||
end
|
||||
|
||||
local keymap_opts = { buffer = buf, noremap = true, silent = true, nowait = true }
|
||||
|
||||
-- Allow once
|
||||
vim.keymap.set("n", "y", function()
|
||||
close_and_respond(true, "allow")
|
||||
end, keymap_opts)
|
||||
vim.keymap.set("n", "<CR>", function()
|
||||
close_and_respond(true, "allow")
|
||||
end, keymap_opts)
|
||||
|
||||
-- Allow this session
|
||||
vim.keymap.set("n", "s", function()
|
||||
close_and_respond(true, "allow_session")
|
||||
end, keymap_opts)
|
||||
|
||||
-- Add to allow list
|
||||
vim.keymap.set("n", "a", function()
|
||||
close_and_respond(true, "allow_list")
|
||||
end, keymap_opts)
|
||||
|
||||
-- Reject
|
||||
vim.keymap.set("n", "n", function()
|
||||
close_and_respond(false, nil)
|
||||
end, keymap_opts)
|
||||
vim.keymap.set("n", "q", function()
|
||||
close_and_respond(false, nil)
|
||||
end, keymap_opts)
|
||||
vim.keymap.set("n", "<Esc>", function()
|
||||
close_and_respond(false, nil)
|
||||
end, keymap_opts)
|
||||
end
|
||||
|
||||
--- Show approval dialog for bash commands (simple version for backward compatibility)
|
||||
---@param command string The bash command to approve
|
||||
---@param callback fun(approved: boolean) Called with user decision
|
||||
function M.show_bash_approval_simple(command, callback)
|
||||
M.show_bash_approval(command, function(result)
|
||||
callback(result.approved)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,572 +0,0 @@
|
||||
---@mod codetyper.agent.search_replace Search/Replace editing system
|
||||
---@brief [[
|
||||
--- Implements SEARCH/REPLACE block parsing and fuzzy matching for reliable code edits.
|
||||
--- Parses and applies SEARCH/REPLACE blocks from LLM responses.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local params = require("codetyper.params.agents.search_replace").patterns
|
||||
|
||||
---@class SearchReplaceBlock
|
||||
---@field search string The text to search for
|
||||
---@field replace string The text to replace with
|
||||
---@field file_path string|nil Optional file path for multi-file edits
|
||||
|
||||
---@class MatchResult
|
||||
---@field start_line number 1-indexed start line
|
||||
---@field end_line number 1-indexed end line
|
||||
---@field start_col number 1-indexed start column (for partial line matches)
|
||||
---@field end_col number 1-indexed end column
|
||||
---@field strategy string Which matching strategy succeeded
|
||||
---@field confidence number Match confidence (0.0-1.0)
|
||||
|
||||
--- Parse SEARCH/REPLACE blocks from LLM response
|
||||
--- Supports multiple formats:
|
||||
--- Format 1 (dash style):
|
||||
--- ------- SEARCH
|
||||
--- old code
|
||||
--- =======
|
||||
--- new code
|
||||
--- +++++++ REPLACE
|
||||
---
|
||||
--- Format 2 (claude style):
|
||||
--- <<<<<<< SEARCH
|
||||
--- old code
|
||||
--- =======
|
||||
--- new code
|
||||
--- >>>>>>> REPLACE
|
||||
---
|
||||
--- Format 3 (simple):
|
||||
--- [SEARCH]
|
||||
--- old code
|
||||
--- [REPLACE]
|
||||
--- new code
|
||||
--- [END]
|
||||
---
|
||||
---@param response string LLM response text
|
||||
---@return SearchReplaceBlock[]
|
||||
function M.parse_blocks(response)
|
||||
local blocks = {}
|
||||
|
||||
-- Try dash-style format: ------- SEARCH ... ======= ... +++++++ REPLACE
|
||||
for search, replace in response:gmatch(params.dash_style) do
|
||||
table.insert(blocks, { search = search, replace = replace })
|
||||
end
|
||||
|
||||
if #blocks > 0 then
|
||||
return blocks
|
||||
end
|
||||
|
||||
-- Try claude-style format: <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE
|
||||
for search, replace in response:gmatch(params.claude_style) do
|
||||
table.insert(blocks, { search = search, replace = replace })
|
||||
end
|
||||
|
||||
if #blocks > 0 then
|
||||
return blocks
|
||||
end
|
||||
|
||||
-- Try simple format: [SEARCH] ... [REPLACE] ... [END]
|
||||
for search, replace in response:gmatch(params.simple_style) do
|
||||
table.insert(blocks, { search = search, replace = replace })
|
||||
end
|
||||
|
||||
if #blocks > 0 then
|
||||
return blocks
|
||||
end
|
||||
|
||||
-- Try markdown diff format: ```diff ... ```
|
||||
local diff_block = response:match(params.diff_block)
|
||||
if diff_block then
|
||||
local old_lines = {}
|
||||
local new_lines = {}
|
||||
for line in diff_block:gmatch("[^\n]+") do
|
||||
if line:match("^%-[^%-]") then
|
||||
-- Removed line (starts with single -)
|
||||
table.insert(old_lines, line:sub(2))
|
||||
elseif line:match("^%+[^%+]") then
|
||||
-- Added line (starts with single +)
|
||||
table.insert(new_lines, line:sub(2))
|
||||
elseif line:match("^%s") or line:match("^[^%-%+@]") then
|
||||
-- Context line
|
||||
table.insert(old_lines, line:match("^%s?(.*)"))
|
||||
table.insert(new_lines, line:match("^%s?(.*)"))
|
||||
end
|
||||
end
|
||||
if #old_lines > 0 or #new_lines > 0 then
|
||||
table.insert(blocks, {
|
||||
search = table.concat(old_lines, "\n"),
|
||||
replace = table.concat(new_lines, "\n"),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return blocks
|
||||
end
|
||||
|
||||
--- Get indentation of a line
|
||||
---@param line string
|
||||
---@return string
|
||||
local function get_indentation(line)
|
||||
if not line then
|
||||
return ""
|
||||
end
|
||||
return line:match("^(%s*)") or ""
|
||||
end
|
||||
|
||||
--- Normalize whitespace in a string (collapse multiple spaces to one)
|
||||
---@param str string
|
||||
---@return string
|
||||
local function normalize_whitespace(str)
|
||||
-- Wrap in parentheses to only return first value (gsub returns string + count)
|
||||
return (str:gsub("%s+", " "):gsub("^%s*", ""):gsub("%s*$", ""))
|
||||
end
|
||||
|
||||
--- Trim trailing whitespace from each line
|
||||
---@param str string
|
||||
---@return string
|
||||
local function trim_lines(str)
|
||||
local lines = vim.split(str, "\n", { plain = true })
|
||||
for i, line in ipairs(lines) do
|
||||
-- Wrap in parentheses to only get string, not count
|
||||
lines[i] = (line:gsub("%s+$", ""))
|
||||
end
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
--- Calculate Levenshtein distance between two strings
|
||||
---@param s1 string
|
||||
---@param s2 string
|
||||
---@return number
|
||||
local function levenshtein(s1, s2)
|
||||
local len1, len2 = #s1, #s2
|
||||
if len1 == 0 then
|
||||
return len2
|
||||
end
|
||||
if len2 == 0 then
|
||||
return len1
|
||||
end
|
||||
|
||||
local matrix = {}
|
||||
for i = 0, len1 do
|
||||
matrix[i] = { [0] = i }
|
||||
end
|
||||
for j = 0, len2 do
|
||||
matrix[0][j] = j
|
||||
end
|
||||
|
||||
for i = 1, len1 do
|
||||
for j = 1, len2 do
|
||||
local cost = (s1:sub(i, i) == s2:sub(j, j)) and 0 or 1
|
||||
matrix[i][j] = math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
return matrix[len1][len2]
|
||||
end
|
||||
|
||||
--- Calculate similarity ratio (0.0-1.0) between two strings
|
||||
---@param s1 string
|
||||
---@param s2 string
|
||||
---@return number
|
||||
local function similarity(s1, s2)
|
||||
if s1 == s2 then
|
||||
return 1.0
|
||||
end
|
||||
local max_len = math.max(#s1, #s2)
|
||||
if max_len == 0 then
|
||||
return 1.0
|
||||
end
|
||||
local distance = levenshtein(s1, s2)
|
||||
return 1.0 - (distance / max_len)
|
||||
end
|
||||
|
||||
--- Strategy 1: Exact match
|
||||
---@param content_lines string[]
|
||||
---@param search_lines string[]
|
||||
---@return MatchResult|nil
|
||||
local function exact_match(content_lines, search_lines)
|
||||
if #search_lines == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i = 1, #content_lines - #search_lines + 1 do
|
||||
local match = true
|
||||
for j = 1, #search_lines do
|
||||
if content_lines[i + j - 1] ~= search_lines[j] then
|
||||
match = false
|
||||
break
|
||||
end
|
||||
end
|
||||
if match then
|
||||
return {
|
||||
start_line = i,
|
||||
end_line = i + #search_lines - 1,
|
||||
start_col = 1,
|
||||
end_col = #content_lines[i + #search_lines - 1],
|
||||
strategy = "exact",
|
||||
confidence = 1.0,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Strategy 2: Line-trimmed match (ignore trailing whitespace)
|
||||
---@param content_lines string[]
|
||||
---@param search_lines string[]
|
||||
---@return MatchResult|nil
|
||||
local function line_trimmed_match(content_lines, search_lines)
|
||||
if #search_lines == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local trimmed_search = {}
|
||||
for _, line in ipairs(search_lines) do
|
||||
table.insert(trimmed_search, (line:gsub("%s+$", "")))
|
||||
end
|
||||
|
||||
for i = 1, #content_lines - #search_lines + 1 do
|
||||
local match = true
|
||||
for j = 1, #search_lines do
|
||||
local trimmed_content = content_lines[i + j - 1]:gsub("%s+$", "")
|
||||
if trimmed_content ~= trimmed_search[j] then
|
||||
match = false
|
||||
break
|
||||
end
|
||||
end
|
||||
if match then
|
||||
return {
|
||||
start_line = i,
|
||||
end_line = i + #search_lines - 1,
|
||||
start_col = 1,
|
||||
end_col = #content_lines[i + #search_lines - 1],
|
||||
strategy = "line_trimmed",
|
||||
confidence = 0.95,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Strategy 3: Indentation-flexible match (normalize indentation)
|
||||
---@param content_lines string[]
|
||||
---@param search_lines string[]
|
||||
---@return MatchResult|nil
|
||||
local function indentation_flexible_match(content_lines, search_lines)
|
||||
if #search_lines == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Get base indentation from search (first non-empty line)
|
||||
local search_indent = ""
|
||||
for _, line in ipairs(search_lines) do
|
||||
if line:match("%S") then
|
||||
search_indent = get_indentation(line)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Strip common indentation from search
|
||||
local stripped_search = {}
|
||||
for _, line in ipairs(search_lines) do
|
||||
if line:match("^" .. vim.pesc(search_indent)) then
|
||||
table.insert(stripped_search, line:sub(#search_indent + 1))
|
||||
else
|
||||
table.insert(stripped_search, line)
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, #content_lines - #search_lines + 1 do
|
||||
-- Get content indentation at this position
|
||||
local content_indent = ""
|
||||
for j = 0, #search_lines - 1 do
|
||||
local line = content_lines[i + j]
|
||||
if line:match("%S") then
|
||||
content_indent = get_indentation(line)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local match = true
|
||||
for j = 1, #search_lines do
|
||||
local content_line = content_lines[i + j - 1]
|
||||
local expected = content_indent .. stripped_search[j]
|
||||
|
||||
-- Compare with normalized indentation
|
||||
if content_line:gsub("%s+$", "") ~= expected:gsub("%s+$", "") then
|
||||
match = false
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if match then
|
||||
return {
|
||||
start_line = i,
|
||||
end_line = i + #search_lines - 1,
|
||||
start_col = 1,
|
||||
end_col = #content_lines[i + #search_lines - 1],
|
||||
strategy = "indentation_flexible",
|
||||
confidence = 0.9,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Strategy 4: Block anchor match (match first/last lines, fuzzy middle)
|
||||
---@param content_lines string[]
|
||||
---@param search_lines string[]
|
||||
---@return MatchResult|nil
|
||||
local function block_anchor_match(content_lines, search_lines)
|
||||
if #search_lines < 2 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local first_search = search_lines[1]:gsub("%s+$", "")
|
||||
local last_search = search_lines[#search_lines]:gsub("%s+$", "")
|
||||
|
||||
-- Find potential start positions
|
||||
local candidates = {}
|
||||
for i = 1, #content_lines - #search_lines + 1 do
|
||||
local first_content = content_lines[i]:gsub("%s+$", "")
|
||||
if similarity(first_content, first_search) > 0.8 then
|
||||
-- Check if last line also matches
|
||||
local last_idx = i + #search_lines - 1
|
||||
if last_idx <= #content_lines then
|
||||
local last_content = content_lines[last_idx]:gsub("%s+$", "")
|
||||
if similarity(last_content, last_search) > 0.8 then
|
||||
-- Calculate overall similarity
|
||||
local total_sim = 0
|
||||
for j = 1, #search_lines do
|
||||
local c = content_lines[i + j - 1]:gsub("%s+$", "")
|
||||
local s = search_lines[j]:gsub("%s+$", "")
|
||||
total_sim = total_sim + similarity(c, s)
|
||||
end
|
||||
local avg_sim = total_sim / #search_lines
|
||||
if avg_sim > 0.7 then
|
||||
table.insert(candidates, { start = i, similarity = avg_sim })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Return best match
|
||||
if #candidates > 0 then
|
||||
table.sort(candidates, function(a, b)
|
||||
return a.similarity > b.similarity
|
||||
end)
|
||||
local best = candidates[1]
|
||||
return {
|
||||
start_line = best.start,
|
||||
end_line = best.start + #search_lines - 1,
|
||||
start_col = 1,
|
||||
end_col = #content_lines[best.start + #search_lines - 1],
|
||||
strategy = "block_anchor",
|
||||
confidence = best.similarity * 0.85,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Strategy 5: Whitespace-normalized match
|
||||
---@param content_lines string[]
|
||||
---@param search_lines string[]
|
||||
---@return MatchResult|nil
|
||||
local function whitespace_normalized_match(content_lines, search_lines)
|
||||
if #search_lines == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Normalize search lines
|
||||
local norm_search = {}
|
||||
for _, line in ipairs(search_lines) do
|
||||
table.insert(norm_search, normalize_whitespace(line))
|
||||
end
|
||||
|
||||
for i = 1, #content_lines - #search_lines + 1 do
|
||||
local match = true
|
||||
for j = 1, #search_lines do
|
||||
local norm_content = normalize_whitespace(content_lines[i + j - 1])
|
||||
if norm_content ~= norm_search[j] then
|
||||
match = false
|
||||
break
|
||||
end
|
||||
end
|
||||
if match then
|
||||
return {
|
||||
start_line = i,
|
||||
end_line = i + #search_lines - 1,
|
||||
start_col = 1,
|
||||
end_col = #content_lines[i + #search_lines - 1],
|
||||
strategy = "whitespace_normalized",
|
||||
confidence = 0.8,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Find the best match for search text in content
|
||||
---@param content string File content
|
||||
---@param search string Text to search for
|
||||
---@return MatchResult|nil
|
||||
function M.find_match(content, search)
|
||||
local content_lines = vim.split(content, "\n", { plain = true })
|
||||
local search_lines = vim.split(search, "\n", { plain = true })
|
||||
|
||||
-- Remove trailing empty lines from search
|
||||
while #search_lines > 0 and search_lines[#search_lines]:match("^%s*$") do
|
||||
table.remove(search_lines)
|
||||
end
|
||||
|
||||
if #search_lines == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Try strategies in order of strictness
|
||||
local strategies = {
|
||||
exact_match,
|
||||
line_trimmed_match,
|
||||
indentation_flexible_match,
|
||||
block_anchor_match,
|
||||
whitespace_normalized_match,
|
||||
}
|
||||
|
||||
for _, strategy in ipairs(strategies) do
|
||||
local result = strategy(content_lines, search_lines)
|
||||
if result then
|
||||
return result
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Apply a single SEARCH/REPLACE block to content
|
||||
---@param content string Original file content
|
||||
---@param block SearchReplaceBlock
|
||||
---@return string|nil new_content
|
||||
---@return MatchResult|nil match_info
|
||||
---@return string|nil error
|
||||
function M.apply_block(content, block)
|
||||
local match = M.find_match(content, block.search)
|
||||
if not match then
|
||||
return nil, nil, "Could not find search text in file"
|
||||
end
|
||||
|
||||
local content_lines = vim.split(content, "\n", { plain = true })
|
||||
local replace_lines = vim.split(block.replace, "\n", { plain = true })
|
||||
|
||||
-- Adjust indentation of replacement to match original
|
||||
local original_indent = get_indentation(content_lines[match.start_line])
|
||||
local replace_indent = ""
|
||||
for _, line in ipairs(replace_lines) do
|
||||
if line:match("%S") then
|
||||
replace_indent = get_indentation(line)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply indentation adjustment
|
||||
local adjusted_replace = {}
|
||||
for _, line in ipairs(replace_lines) do
|
||||
if line:match("^" .. vim.pesc(replace_indent)) then
|
||||
table.insert(adjusted_replace, original_indent .. line:sub(#replace_indent + 1))
|
||||
elseif line:match("^%s*$") then
|
||||
table.insert(adjusted_replace, "")
|
||||
else
|
||||
table.insert(adjusted_replace, original_indent .. line)
|
||||
end
|
||||
end
|
||||
|
||||
-- Build new content
|
||||
local new_lines = {}
|
||||
for i = 1, match.start_line - 1 do
|
||||
table.insert(new_lines, content_lines[i])
|
||||
end
|
||||
for _, line in ipairs(adjusted_replace) do
|
||||
table.insert(new_lines, line)
|
||||
end
|
||||
for i = match.end_line + 1, #content_lines do
|
||||
table.insert(new_lines, content_lines[i])
|
||||
end
|
||||
|
||||
return table.concat(new_lines, "\n"), match, nil
|
||||
end
|
||||
|
||||
--- Apply multiple SEARCH/REPLACE blocks to content
|
||||
---@param content string Original file content
|
||||
---@param blocks SearchReplaceBlock[]
|
||||
---@return string new_content
|
||||
---@return table results Array of {success: boolean, match: MatchResult|nil, error: string|nil}
|
||||
function M.apply_blocks(content, blocks)
|
||||
local current_content = content
|
||||
local results = {}
|
||||
|
||||
for _, block in ipairs(blocks) do
|
||||
local new_content, match, err = M.apply_block(current_content, block)
|
||||
if new_content then
|
||||
current_content = new_content
|
||||
table.insert(results, { success = true, match = match })
|
||||
else
|
||||
table.insert(results, { success = false, error = err })
|
||||
end
|
||||
end
|
||||
|
||||
return current_content, results
|
||||
end
|
||||
|
||||
--- Apply SEARCH/REPLACE blocks to a buffer
|
||||
---@param bufnr number Buffer number
|
||||
---@param blocks SearchReplaceBlock[]
|
||||
---@return boolean success
|
||||
---@return string|nil error
|
||||
function M.apply_to_buffer(bufnr, blocks)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return false, "Invalid buffer"
|
||||
end
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local content = table.concat(lines, "\n")
|
||||
|
||||
local new_content, results = M.apply_blocks(content, blocks)
|
||||
|
||||
-- Check for any failures
|
||||
local failures = {}
|
||||
for i, result in ipairs(results) do
|
||||
if not result.success then
|
||||
table.insert(failures, string.format("Block %d: %s", i, result.error or "unknown error"))
|
||||
end
|
||||
end
|
||||
|
||||
if #failures > 0 then
|
||||
return false, table.concat(failures, "; ")
|
||||
end
|
||||
|
||||
-- Apply to buffer
|
||||
local new_lines = vim.split(new_content, "\n", { plain = true })
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines)
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Check if response contains SEARCH/REPLACE blocks
|
||||
---@param response string
|
||||
---@return boolean
|
||||
function M.has_blocks(response)
|
||||
return #M.parse_blocks(response) > 0
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,117 +0,0 @@
|
||||
---@mod codetyper.agent.intent Intent detection from prompts
|
||||
---@brief [[
|
||||
--- Parses prompt content to determine user intent and target scope.
|
||||
--- Intents determine how the generated code should be applied.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class Intent
|
||||
---@field type string "complete"|"refactor"|"add"|"fix"|"document"|"test"|"explain"|"optimize"
|
||||
---@field scope_hint string|nil "function"|"class"|"block"|"file"|"selection"|nil
|
||||
---@field confidence number 0.0-1.0 how confident we are about the intent
|
||||
---@field action string "replace"|"insert"|"append"|"none"
|
||||
---@field keywords string[] Keywords that triggered this intent
|
||||
|
||||
local params = require("codetyper.params.agents.intent")
|
||||
local intent_patterns = params.intent_patterns
|
||||
local scope_patterns = params.scope_patterns
|
||||
local prompts = require("codetyper.prompts.agents.intent")
|
||||
|
||||
--- Detect intent from prompt content
|
||||
---@param prompt string The prompt content
|
||||
---@return Intent
|
||||
function M.detect(prompt)
|
||||
local lower = prompt:lower()
|
||||
local best_match = nil
|
||||
local best_priority = 999
|
||||
local matched_keywords = {}
|
||||
|
||||
-- Check each intent type
|
||||
for intent_type, config in pairs(intent_patterns) do
|
||||
for _, pattern in ipairs(config.patterns) do
|
||||
if lower:find(pattern, 1, true) then
|
||||
if config.priority < best_priority then
|
||||
best_match = intent_type
|
||||
best_priority = config.priority
|
||||
matched_keywords = { pattern }
|
||||
elseif config.priority == best_priority and best_match == intent_type then
|
||||
table.insert(matched_keywords, pattern)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Default to "add" if no clear intent
|
||||
if not best_match then
|
||||
best_match = "add"
|
||||
matched_keywords = {}
|
||||
end
|
||||
|
||||
local config = intent_patterns[best_match]
|
||||
|
||||
-- Detect scope hint from prompt
|
||||
local scope_hint = config.scope_hint
|
||||
for pattern, hint in pairs(scope_patterns) do
|
||||
if lower:find(pattern, 1, true) then
|
||||
scope_hint = hint or scope_hint
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate confidence based on keyword matches
|
||||
local confidence = 0.5 + (#matched_keywords * 0.15)
|
||||
confidence = math.min(confidence, 1.0)
|
||||
|
||||
return {
|
||||
type = best_match,
|
||||
scope_hint = scope_hint,
|
||||
confidence = confidence,
|
||||
action = config.action,
|
||||
keywords = matched_keywords,
|
||||
}
|
||||
end
|
||||
|
||||
--- Check if intent requires code modification
|
||||
---@param intent Intent
|
||||
---@return boolean
|
||||
function M.modifies_code(intent)
|
||||
return intent.action ~= "none"
|
||||
end
|
||||
|
||||
--- Check if intent should replace existing code
|
||||
---@param intent Intent
|
||||
---@return boolean
|
||||
function M.is_replacement(intent)
|
||||
return intent.action == "replace"
|
||||
end
|
||||
|
||||
--- Check if intent adds new code
|
||||
---@param intent Intent
|
||||
---@return boolean
|
||||
function M.is_insertion(intent)
|
||||
return intent.action == "insert" or intent.action == "append"
|
||||
end
|
||||
|
||||
--- Get system prompt modifier based on intent
|
||||
---@param intent Intent
|
||||
---@return string
|
||||
function M.get_prompt_modifier(intent)
|
||||
local modifiers = prompts.modifiers
|
||||
return modifiers[intent.type] or modifiers.add
|
||||
end
|
||||
|
||||
--- Format intent for logging
|
||||
---@param intent Intent
|
||||
---@return string
|
||||
function M.format(intent)
|
||||
return string.format(
|
||||
"%s (scope: %s, action: %s, confidence: %.2f)",
|
||||
intent.type,
|
||||
intent.scope_hint or "auto",
|
||||
intent.action,
|
||||
intent.confidence
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,276 +0,0 @@
|
||||
---@mod codetyper.agent.confidence Response confidence scoring
|
||||
---@brief [[
|
||||
--- Scores LLM responses using heuristics to decide if escalation is needed.
|
||||
--- Returns 0.0-1.0 where higher = more confident the response is good.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local params = require("codetyper.params.agents.confidence")
|
||||
|
||||
--- Heuristic weights (must sum to 1.0)
|
||||
M.weights = params.weights
|
||||
|
||||
--- Uncertainty phrases that indicate low confidence
|
||||
local uncertainty_phrases = params.uncertainty_phrases
|
||||
|
||||
|
||||
--- Score based on response length relative to prompt
|
||||
---@param response string
|
||||
---@param prompt string
|
||||
---@return number 0.0-1.0
|
||||
local function score_length(response, prompt)
|
||||
local response_len = #response
|
||||
local prompt_len = #prompt
|
||||
|
||||
-- Very short response to long prompt is suspicious
|
||||
if prompt_len > 50 and response_len < 20 then
|
||||
return 0.2
|
||||
end
|
||||
|
||||
-- Response should generally be longer than prompt for code generation
|
||||
local ratio = response_len / math.max(prompt_len, 1)
|
||||
|
||||
if ratio < 0.5 then
|
||||
return 0.3
|
||||
elseif ratio < 1.0 then
|
||||
return 0.6
|
||||
elseif ratio < 2.0 then
|
||||
return 0.8
|
||||
else
|
||||
return 1.0
|
||||
end
|
||||
end
|
||||
|
||||
--- Score based on uncertainty phrases
|
||||
---@param response string
|
||||
---@return number 0.0-1.0
|
||||
local function score_uncertainty(response)
|
||||
local lower = response:lower()
|
||||
local found = 0
|
||||
|
||||
for _, phrase in ipairs(uncertainty_phrases) do
|
||||
if lower:find(phrase:lower(), 1, true) then
|
||||
found = found + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- More uncertainty phrases = lower score
|
||||
if found == 0 then
|
||||
return 1.0
|
||||
elseif found == 1 then
|
||||
return 0.7
|
||||
elseif found == 2 then
|
||||
return 0.5
|
||||
else
|
||||
return 0.2
|
||||
end
|
||||
end
|
||||
|
||||
--- Score based on syntax completeness
|
||||
---@param response string
|
||||
---@return number 0.0-1.0
|
||||
local function score_syntax(response)
|
||||
local score = 1.0
|
||||
|
||||
-- Check bracket balance
|
||||
if not require("codetyper.support.utils").check_brackets(response) then
|
||||
score = score - 0.4
|
||||
end
|
||||
|
||||
-- Check for common incomplete patterns
|
||||
|
||||
-- Lua: unbalanced end/function
|
||||
local function_count = select(2, response:gsub("function%s*%(", ""))
|
||||
+ select(2, response:gsub("function%s+%w+%(", ""))
|
||||
local end_count = select(2, response:gsub("%f[%w]end%f[%W]", ""))
|
||||
if function_count > end_count + 2 then
|
||||
score = score - 0.2
|
||||
end
|
||||
|
||||
-- JavaScript/TypeScript: unclosed template literals
|
||||
local backtick_count = select(2, response:gsub("`", ""))
|
||||
if backtick_count % 2 ~= 0 then
|
||||
score = score - 0.2
|
||||
end
|
||||
|
||||
-- String quotes balance
|
||||
local double_quotes = select(2, response:gsub('"', ""))
|
||||
local single_quotes = select(2, response:gsub("'", ""))
|
||||
-- Allow for escaped quotes by being lenient
|
||||
if double_quotes % 2 ~= 0 and not response:find('\\"') then
|
||||
score = score - 0.1
|
||||
end
|
||||
if single_quotes % 2 ~= 0 and not response:find("\\'") then
|
||||
score = score - 0.1
|
||||
end
|
||||
|
||||
return math.max(0, score)
|
||||
end
|
||||
|
||||
--- Score based on line repetition
|
||||
---@param response string
|
||||
---@return number 0.0-1.0
|
||||
local function score_repetition(response)
|
||||
local lines = vim.split(response, "\n", { plain = true })
|
||||
if #lines < 3 then
|
||||
return 1.0
|
||||
end
|
||||
|
||||
-- Count duplicate non-empty lines
|
||||
local seen = {}
|
||||
local duplicates = 0
|
||||
|
||||
for _, line in ipairs(lines) do
|
||||
local trimmed = vim.trim(line)
|
||||
if #trimmed > 10 then -- Only check substantial lines
|
||||
if seen[trimmed] then
|
||||
duplicates = duplicates + 1
|
||||
end
|
||||
seen[trimmed] = true
|
||||
end
|
||||
end
|
||||
|
||||
local dup_ratio = duplicates / #lines
|
||||
|
||||
if dup_ratio < 0.1 then
|
||||
return 1.0
|
||||
elseif dup_ratio < 0.2 then
|
||||
return 0.8
|
||||
elseif dup_ratio < 0.3 then
|
||||
return 0.5
|
||||
else
|
||||
return 0.2 -- High repetition = degraded output
|
||||
end
|
||||
end
|
||||
|
||||
--- Score based on truncation indicators
|
||||
---@param response string
|
||||
---@return number 0.0-1.0
|
||||
local function score_truncation(response)
|
||||
local score = 1.0
|
||||
|
||||
-- Ends with ellipsis
|
||||
if response:match("%.%.%.$") then
|
||||
score = score - 0.5
|
||||
end
|
||||
|
||||
-- Ends with incomplete comment
|
||||
if response:match("/%*[^*/]*$") then -- Unclosed /* comment
|
||||
score = score - 0.4
|
||||
end
|
||||
if response:match("<!%-%-[^>]*$") then -- Unclosed HTML comment
|
||||
score = score - 0.4
|
||||
end
|
||||
|
||||
-- Ends mid-statement (common patterns)
|
||||
local trimmed = vim.trim(response)
|
||||
local last_char = trimmed:sub(-1)
|
||||
|
||||
-- Suspicious endings
|
||||
if last_char == "=" or last_char == "," or last_char == "(" then
|
||||
score = score - 0.3
|
||||
end
|
||||
|
||||
-- Very short last line after long response
|
||||
local lines = vim.split(response, "\n", { plain = true })
|
||||
if #lines > 5 then
|
||||
local last_line = vim.trim(lines[#lines])
|
||||
if #last_line < 5 and not last_line:match("^[%}%]%)%;end]") then
|
||||
score = score - 0.2
|
||||
end
|
||||
end
|
||||
|
||||
return math.max(0, score)
|
||||
end
|
||||
|
||||
---@class ConfidenceBreakdown
|
||||
---@field length number
|
||||
---@field uncertainty number
|
||||
---@field syntax number
|
||||
---@field repetition number
|
||||
---@field truncation number
|
||||
---@field weighted_total number
|
||||
|
||||
--- Calculate confidence score for response
|
||||
---@param response string The LLM response
|
||||
---@param prompt string The original prompt
|
||||
---@param context table|nil Additional context (unused for now)
|
||||
---@return number confidence 0.0-1.0
|
||||
---@return ConfidenceBreakdown breakdown Individual scores
|
||||
function M.score(response, prompt, context)
|
||||
_ = context -- Reserved for future use
|
||||
|
||||
if not response or #response == 0 then
|
||||
return 0,
|
||||
{
|
||||
length = 0,
|
||||
uncertainty = 0,
|
||||
syntax = 0,
|
||||
repetition = 0,
|
||||
truncation = 0,
|
||||
weighted_total = 0,
|
||||
}
|
||||
end
|
||||
|
||||
local scores = {
|
||||
length = score_length(response, prompt or ""),
|
||||
uncertainty = score_uncertainty(response),
|
||||
syntax = score_syntax(response),
|
||||
repetition = score_repetition(response),
|
||||
truncation = score_truncation(response),
|
||||
}
|
||||
|
||||
-- Calculate weighted total
|
||||
local weighted = 0
|
||||
for key, weight in pairs(M.weights) do
|
||||
weighted = weighted + (scores[key] * weight)
|
||||
end
|
||||
|
||||
scores.weighted_total = weighted
|
||||
|
||||
return weighted, scores
|
||||
end
|
||||
|
||||
--- Check if response needs escalation
|
||||
---@param confidence number
|
||||
---@param threshold number|nil Default: 0.7
|
||||
---@return boolean needs_escalation
|
||||
function M.needs_escalation(confidence, threshold)
|
||||
threshold = threshold or 0.7
|
||||
return confidence < threshold
|
||||
end
|
||||
|
||||
--- Get human-readable confidence level
|
||||
---@param confidence number
|
||||
---@return string
|
||||
function M.level_name(confidence)
|
||||
if confidence >= 0.9 then
|
||||
return "excellent"
|
||||
elseif confidence >= 0.8 then
|
||||
return "good"
|
||||
elseif confidence >= 0.7 then
|
||||
return "acceptable"
|
||||
elseif confidence >= 0.5 then
|
||||
return "uncertain"
|
||||
else
|
||||
return "poor"
|
||||
end
|
||||
end
|
||||
|
||||
--- Format breakdown for logging
|
||||
---@param breakdown ConfidenceBreakdown
|
||||
---@return string
|
||||
function M.format_breakdown(breakdown)
|
||||
return string.format(
|
||||
"len:%.2f unc:%.2f syn:%.2f rep:%.2f tru:%.2f = %.2f",
|
||||
breakdown.length,
|
||||
breakdown.uncertainty,
|
||||
breakdown.syntax,
|
||||
breakdown.repetition,
|
||||
breakdown.truncation,
|
||||
breakdown.weighted_total
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -348,49 +348,29 @@ end
|
||||
---@param context table Context information
|
||||
---@param callback fun(response: string|nil, error: string|nil)
|
||||
function M.generate(prompt, context, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
ensure_initialized()
|
||||
|
||||
if not M.state.oauth_token then
|
||||
local err = "Copilot not authenticated. Please set up copilot.lua or copilot.vim first."
|
||||
logs.error(err)
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
local model = get_model()
|
||||
logs.request("copilot", model)
|
||||
logs.thinking("Refreshing authentication token...")
|
||||
|
||||
refresh_token(function(token, err)
|
||||
if err then
|
||||
logs.error(err)
|
||||
utils.notify(err, vim.log.levels.ERROR)
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
logs.thinking("Building request body...")
|
||||
local body = build_request_body(prompt, context)
|
||||
|
||||
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to Copilot API...")
|
||||
|
||||
utils.notify("Sending request to Copilot...", vim.log.levels.INFO)
|
||||
|
||||
make_request(token, body, function(response, request_err, usage)
|
||||
if request_err then
|
||||
logs.error(request_err)
|
||||
utils.notify(request_err, vim.log.levels.ERROR)
|
||||
callback(nil, request_err)
|
||||
else
|
||||
if usage then
|
||||
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
|
||||
end
|
||||
logs.thinking("Response received, extracting code...")
|
||||
logs.info("Code generated successfully")
|
||||
utils.notify("Code generated successfully", vim.log.levels.INFO)
|
||||
callback(response, nil)
|
||||
end
|
||||
@@ -408,305 +388,4 @@ function M.validate()
|
||||
return true
|
||||
end
|
||||
|
||||
--- Generate with tool use support for agentic mode
|
||||
---@param messages table[] Conversation history
|
||||
---@param context table Context information
|
||||
---@param tool_definitions table Tool definitions
|
||||
---@param callback fun(response: table|nil, error: string|nil)
|
||||
function M.generate_with_tools(messages, context, tool_definitions, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
ensure_initialized()
|
||||
|
||||
if not M.state.oauth_token then
|
||||
local err = "Copilot not authenticated"
|
||||
logs.error(err)
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
local model = get_model()
|
||||
logs.request("copilot", model)
|
||||
logs.thinking("Refreshing authentication token...")
|
||||
|
||||
refresh_token(function(token, err)
|
||||
if err then
|
||||
logs.error(err)
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
local tools_module = require("codetyper.core.tools")
|
||||
local agent_prompts = require("codetyper.prompts.agents")
|
||||
|
||||
-- Build system prompt with agent instructions and project context
|
||||
local system_prompt = llm.build_system_prompt(context)
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.build_system_prompt()
|
||||
|
||||
-- Format messages for Copilot (OpenAI-compatible format)
|
||||
local copilot_messages = { { role = "system", content = system_prompt } }
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg.role == "user" then
|
||||
-- User messages - handle string or table content
|
||||
if type(msg.content) == "string" then
|
||||
table.insert(copilot_messages, { role = "user", content = msg.content })
|
||||
elseif type(msg.content) == "table" then
|
||||
-- Handle complex content (like tool results from user perspective)
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if part.type == "tool_result" then
|
||||
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
|
||||
elseif part.type == "text" then
|
||||
table.insert(text_parts, part.text or "")
|
||||
end
|
||||
end
|
||||
if #text_parts > 0 then
|
||||
table.insert(copilot_messages, { role = "user", content = table.concat(text_parts, "\n") })
|
||||
end
|
||||
end
|
||||
elseif msg.role == "assistant" then
|
||||
-- Assistant messages - must preserve tool_calls if present
|
||||
local assistant_msg = {
|
||||
role = "assistant",
|
||||
content = type(msg.content) == "string" and msg.content or nil,
|
||||
}
|
||||
-- Convert tool_calls to OpenAI format for the API
|
||||
if msg.tool_calls and #msg.tool_calls > 0 then
|
||||
assistant_msg.tool_calls = {}
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
-- Convert from parsed format {id, name, parameters} to OpenAI format
|
||||
local openai_tc = {
|
||||
id = tc.id,
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = tc.name,
|
||||
arguments = vim.json.encode(tc.parameters or {}),
|
||||
},
|
||||
}
|
||||
table.insert(assistant_msg.tool_calls, openai_tc)
|
||||
end
|
||||
-- Ensure content is not nil when tool_calls present
|
||||
if assistant_msg.content == nil then
|
||||
assistant_msg.content = ""
|
||||
end
|
||||
end
|
||||
table.insert(copilot_messages, assistant_msg)
|
||||
elseif msg.role == "tool" then
|
||||
-- Tool result messages - must have tool_call_id
|
||||
table.insert(copilot_messages, {
|
||||
role = "tool",
|
||||
tool_call_id = msg.tool_call_id,
|
||||
content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local body = {
|
||||
model = get_model(),
|
||||
messages = copilot_messages,
|
||||
max_tokens = 4096,
|
||||
temperature = 0.3,
|
||||
stream = false,
|
||||
tools = tools_module.to_openai_format(),
|
||||
tool_choice = "auto", -- Encourage the model to use tools when appropriate
|
||||
}
|
||||
|
||||
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com")
|
||||
.. "/chat/completions"
|
||||
local json_body = vim.json.encode(body)
|
||||
|
||||
local prompt_estimate = logs.estimate_tokens(json_body)
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to Copilot API...")
|
||||
|
||||
-- Log request to debug file
|
||||
local debug_log_path = vim.fn.expand("~/.local/codetyper-debug.log")
|
||||
local debug_f = io.open(debug_log_path, "a")
|
||||
if debug_f then
|
||||
debug_f:write(os.date("[%Y-%m-%d %H:%M:%S] ") .. "COPILOT REQUEST\n")
|
||||
debug_f:write("Messages count: " .. #copilot_messages .. "\n")
|
||||
for i, m in ipairs(copilot_messages) do
|
||||
debug_f:write(string.format(" [%d] role=%s, has_tool_calls=%s, has_tool_call_id=%s\n",
|
||||
i, m.role, tostring(m.tool_calls ~= nil), tostring(m.tool_call_id ~= nil)))
|
||||
end
|
||||
debug_f:write("---\n")
|
||||
debug_f:close()
|
||||
end
|
||||
|
||||
local headers = build_headers(token)
|
||||
local cmd = {
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
endpoint,
|
||||
}
|
||||
|
||||
for _, header in ipairs(headers) do
|
||||
table.insert(cmd, "-H")
|
||||
table.insert(cmd, header)
|
||||
end
|
||||
|
||||
table.insert(cmd, "-d")
|
||||
table.insert(cmd, json_body)
|
||||
|
||||
-- Debug logging helper
|
||||
local function debug_log(msg, data)
|
||||
local log_path = vim.fn.expand("~/.local/codetyper-debug.log")
|
||||
local f = io.open(log_path, "a")
|
||||
if f then
|
||||
f:write(os.date("[%Y-%m-%d %H:%M:%S] ") .. msg .. "\n")
|
||||
if data then
|
||||
f:write("DATA: " .. tostring(data):sub(1, 2000) .. "\n")
|
||||
end
|
||||
f:write("---\n")
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
|
||||
-- Prevent double callback calls
|
||||
local callback_called = false
|
||||
|
||||
vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if callback_called then
|
||||
debug_log("on_stdout: callback already called, skipping")
|
||||
return
|
||||
end
|
||||
|
||||
if not data or #data == 0 or (data[1] == "" and #data == 1) then
|
||||
debug_log("on_stdout: empty data")
|
||||
return
|
||||
end
|
||||
|
||||
local response_text = table.concat(data, "\n")
|
||||
debug_log("on_stdout: received response", response_text)
|
||||
|
||||
local ok, response = pcall(vim.json.decode, response_text)
|
||||
|
||||
if not ok then
|
||||
debug_log("JSON parse failed", response_text)
|
||||
callback_called = true
|
||||
|
||||
-- Show the actual response text as the error (truncated if too long)
|
||||
local error_msg = response_text
|
||||
if #error_msg > 200 then
|
||||
error_msg = error_msg:sub(1, 200) .. "..."
|
||||
end
|
||||
|
||||
-- Clean up common patterns
|
||||
if response_text:match("<!DOCTYPE") or response_text:match("<html") then
|
||||
error_msg = "Copilot API returned HTML error page. Service may be unavailable."
|
||||
end
|
||||
|
||||
-- Check for rate limit and suggest Ollama fallback
|
||||
if response_text:match("limit") or response_text:match("Upgrade") or response_text:match("quota") then
|
||||
M.suggest_ollama_fallback(error_msg)
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
logs.error(error_msg)
|
||||
callback(nil, error_msg)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
callback_called = true
|
||||
local error_msg = response.error.message or "Copilot API error"
|
||||
|
||||
-- Check for rate limit in structured error
|
||||
if response.error.code == "rate_limit_exceeded" or (error_msg:match("limit") and error_msg:match("plan")) then
|
||||
error_msg = "Copilot rate limit: " .. error_msg
|
||||
M.suggest_ollama_fallback(error_msg)
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
logs.error(error_msg)
|
||||
callback(nil, error_msg)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Log token usage and record cost
|
||||
if response.usage then
|
||||
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
|
||||
|
||||
-- Record usage for cost tracking
|
||||
local cost_tracker = require("codetyper.core.cost")
|
||||
cost_tracker.record_usage(
|
||||
get_model(),
|
||||
response.usage.prompt_tokens or 0,
|
||||
response.usage.completion_tokens or 0,
|
||||
response.usage.prompt_tokens_details and response.usage.prompt_tokens_details.cached_tokens or 0
|
||||
)
|
||||
end
|
||||
|
||||
-- Convert to Claude-like format for parser compatibility
|
||||
local converted = { content = {} }
|
||||
if response.choices and response.choices[1] then
|
||||
local choice = response.choices[1]
|
||||
if choice.message then
|
||||
if choice.message.content then
|
||||
table.insert(converted.content, { type = "text", text = choice.message.content })
|
||||
logs.thinking("Response contains text")
|
||||
end
|
||||
if choice.message.tool_calls then
|
||||
for _, tc in ipairs(choice.message.tool_calls) do
|
||||
local args = {}
|
||||
if tc["function"] and tc["function"].arguments then
|
||||
local ok_args, parsed = pcall(vim.json.decode, tc["function"].arguments)
|
||||
if ok_args then
|
||||
args = parsed
|
||||
end
|
||||
end
|
||||
table.insert(converted.content, {
|
||||
type = "tool_use",
|
||||
id = tc.id,
|
||||
name = tc["function"].name,
|
||||
input = args,
|
||||
})
|
||||
logs.thinking("Tool call: " .. tc["function"].name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
callback_called = true
|
||||
debug_log("on_stdout: success, calling callback")
|
||||
vim.schedule(function()
|
||||
callback(converted, nil)
|
||||
end)
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if callback_called then
|
||||
return
|
||||
end
|
||||
if data and #data > 0 and data[1] ~= "" then
|
||||
debug_log("on_stderr", table.concat(data, "\n"))
|
||||
callback_called = true
|
||||
vim.schedule(function()
|
||||
logs.error("Copilot API request failed: " .. table.concat(data, "\n"))
|
||||
callback(nil, "Copilot API request failed: " .. table.concat(data, "\n"))
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
debug_log("on_exit: code=" .. code .. ", callback_called=" .. tostring(callback_called))
|
||||
if callback_called then
|
||||
return
|
||||
end
|
||||
if code ~= 0 then
|
||||
callback_called = true
|
||||
vim.schedule(function()
|
||||
logs.error("Copilot API request failed with code: " .. code)
|
||||
callback(nil, "Copilot API request failed with code: " .. code)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -167,34 +167,14 @@ end
|
||||
---@param context table Context information
|
||||
---@param callback fun(response: string|nil, error: string|nil) Callback function
|
||||
function M.generate(prompt, context, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local model = get_model()
|
||||
|
||||
-- Log the request
|
||||
logs.request("gemini", model)
|
||||
logs.thinking("Building request body...")
|
||||
|
||||
local body = build_request_body(prompt, context)
|
||||
|
||||
-- Estimate prompt tokens
|
||||
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to Gemini API...")
|
||||
|
||||
utils.notify("Sending request to Gemini...", vim.log.levels.INFO)
|
||||
|
||||
make_request(body, function(response, err, usage)
|
||||
if err then
|
||||
logs.error(err)
|
||||
utils.notify(err, vim.log.levels.ERROR)
|
||||
callback(nil, err)
|
||||
else
|
||||
-- Log token usage
|
||||
if usage then
|
||||
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
|
||||
end
|
||||
logs.thinking("Response received, extracting code...")
|
||||
logs.info("Code generated successfully")
|
||||
utils.notify("Code generated successfully", vim.log.levels.INFO)
|
||||
callback(response, nil)
|
||||
end
|
||||
@@ -211,198 +191,4 @@ function M.validate()
|
||||
return true
|
||||
end
|
||||
|
||||
--- Generate with tool use support for agentic mode
|
||||
---@param messages table[] Conversation history
|
||||
---@param context table Context information
|
||||
---@param tool_definitions table Tool definitions
|
||||
---@param callback fun(response: table|nil, error: string|nil) Callback with raw response
|
||||
function M.generate_with_tools(messages, context, tool_definitions, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local model = get_model()
|
||||
|
||||
logs.request("gemini", model)
|
||||
logs.thinking("Preparing agent request...")
|
||||
|
||||
local api_key = get_api_key()
|
||||
if not api_key then
|
||||
logs.error("Gemini API key not configured")
|
||||
callback(nil, "Gemini API key not configured")
|
||||
return
|
||||
end
|
||||
|
||||
local tools_module = require("codetyper.core.tools")
|
||||
local agent_prompts = require("codetyper.prompts.agents")
|
||||
|
||||
-- Build system prompt with agent instructions
|
||||
local system_prompt = llm.build_system_prompt(context)
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions
|
||||
|
||||
-- Format messages for Gemini
|
||||
local gemini_contents = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
local role = msg.role == "assistant" and "model" or "user"
|
||||
local parts = {}
|
||||
|
||||
if type(msg.content) == "string" then
|
||||
table.insert(parts, { text = msg.content })
|
||||
elseif type(msg.content) == "table" then
|
||||
for _, part in ipairs(msg.content) do
|
||||
if part.type == "tool_result" then
|
||||
table.insert(parts, { text = "[" .. (part.name or "tool") .. " result]: " .. (part.content or "") })
|
||||
elseif part.type == "text" then
|
||||
table.insert(parts, { text = part.text or "" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #parts > 0 then
|
||||
table.insert(gemini_contents, { role = role, parts = parts })
|
||||
end
|
||||
end
|
||||
|
||||
-- Build function declarations for tools
|
||||
local function_declarations = {}
|
||||
for _, tool in ipairs(tools_module.definitions) do
|
||||
local properties = {}
|
||||
local required = {}
|
||||
|
||||
if tool.parameters and tool.parameters.properties then
|
||||
for name, prop in pairs(tool.parameters.properties) do
|
||||
properties[name] = {
|
||||
type = prop.type:upper(),
|
||||
description = prop.description,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
if tool.parameters and tool.parameters.required then
|
||||
required = tool.parameters.required
|
||||
end
|
||||
|
||||
table.insert(function_declarations, {
|
||||
name = tool.name,
|
||||
description = tool.description,
|
||||
parameters = {
|
||||
type = "OBJECT",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
local body = {
|
||||
systemInstruction = {
|
||||
role = "user",
|
||||
parts = { { text = system_prompt } },
|
||||
},
|
||||
contents = gemini_contents,
|
||||
generationConfig = {
|
||||
temperature = 0.3,
|
||||
maxOutputTokens = 4096,
|
||||
},
|
||||
tools = {
|
||||
{ functionDeclarations = function_declarations },
|
||||
},
|
||||
}
|
||||
|
||||
local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key
|
||||
local json_body = vim.json.encode(body)
|
||||
|
||||
local prompt_estimate = logs.estimate_tokens(json_body)
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to Gemini API...")
|
||||
|
||||
local cmd = {
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
url,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-d",
|
||||
json_body,
|
||||
}
|
||||
|
||||
vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if not data or #data == 0 or (data[1] == "" and #data == 1) then
|
||||
return
|
||||
end
|
||||
|
||||
local response_text = table.concat(data, "\n")
|
||||
local ok, response = pcall(vim.json.decode, response_text)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(function()
|
||||
logs.error("Failed to parse Gemini response")
|
||||
callback(nil, "Failed to parse Gemini response")
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
vim.schedule(function()
|
||||
logs.error(response.error.message or "Gemini API error")
|
||||
callback(nil, response.error.message or "Gemini API error")
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Log token usage
|
||||
if response.usageMetadata then
|
||||
logs.response(
|
||||
response.usageMetadata.promptTokenCount or 0,
|
||||
response.usageMetadata.candidatesTokenCount or 0,
|
||||
"stop"
|
||||
)
|
||||
end
|
||||
|
||||
-- Convert to Claude-like format for parser compatibility
|
||||
local converted = { content = {} }
|
||||
if response.candidates and response.candidates[1] then
|
||||
local candidate = response.candidates[1]
|
||||
if candidate.content and candidate.content.parts then
|
||||
for _, part in ipairs(candidate.content.parts) do
|
||||
if part.text then
|
||||
table.insert(converted.content, { type = "text", text = part.text })
|
||||
logs.thinking("Response contains text")
|
||||
elseif part.functionCall then
|
||||
table.insert(converted.content, {
|
||||
type = "tool_use",
|
||||
id = vim.fn.sha256(vim.json.encode(part.functionCall)):sub(1, 16),
|
||||
name = part.functionCall.name,
|
||||
input = part.functionCall.args or {},
|
||||
})
|
||||
logs.thinking("Tool call: " .. part.functionCall.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
callback(converted, nil)
|
||||
end)
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data and #data > 0 and data[1] ~= "" then
|
||||
vim.schedule(function()
|
||||
logs.error("Gemini API request failed: " .. table.concat(data, "\n"))
|
||||
callback(nil, "Gemini API request failed: " .. table.concat(data, "\n"))
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
if code ~= 0 then
|
||||
vim.schedule(function()
|
||||
logs.error("Gemini API request failed with code: " .. code)
|
||||
callback(nil, "Gemini API request failed with code: " .. code)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -137,34 +137,16 @@ end
|
||||
---@param context table Context information
|
||||
---@param callback fun(response: string|nil, error: string|nil) Callback function
|
||||
function M.generate(prompt, context, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local model = get_model()
|
||||
|
||||
-- Log the request
|
||||
logs.request("ollama", model)
|
||||
logs.thinking("Building request body...")
|
||||
|
||||
local body = build_request_body(prompt, context)
|
||||
|
||||
-- Estimate prompt tokens
|
||||
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to Ollama API...")
|
||||
|
||||
utils.notify("Sending request to Ollama...", vim.log.levels.INFO)
|
||||
|
||||
make_request(body, function(response, err, usage)
|
||||
if err then
|
||||
logs.error(err)
|
||||
utils.notify(err, vim.log.levels.ERROR)
|
||||
callback(nil, err)
|
||||
else
|
||||
-- Log token usage
|
||||
if usage then
|
||||
logs.response(usage.prompt_tokens or 0, usage.response_tokens or 0, "end_turn")
|
||||
end
|
||||
logs.thinking("Response received, extracting code...")
|
||||
logs.info("Code generated successfully")
|
||||
utils.notify("Code generated successfully", vim.log.levels.INFO)
|
||||
callback(response, nil)
|
||||
end
|
||||
@@ -211,174 +193,4 @@ function M.validate()
|
||||
return true
|
||||
end
|
||||
|
||||
--- Generate with tool use support for agentic mode (text-based tool calling)
|
||||
---@param messages table[] Conversation history
|
||||
---@param context table Context information
|
||||
---@param tool_definitions table Tool definitions
|
||||
---@param callback fun(response: table|nil, error: string|nil) Callback with Claude-like response format
|
||||
function M.generate_with_tools(messages, context, tool_definitions, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local agent_prompts = require("codetyper.prompts.agents")
|
||||
local tools_module = require("codetyper.core.tools")
|
||||
|
||||
logs.request("ollama", get_model())
|
||||
logs.thinking("Preparing agent request...")
|
||||
|
||||
-- Build system prompt with tool instructions
|
||||
local system_prompt = llm.build_system_prompt(context)
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions
|
||||
|
||||
-- Add tool descriptions
|
||||
system_prompt = system_prompt .. "\n\n## Available Tools\n"
|
||||
system_prompt = system_prompt .. "Call tools by outputting JSON in this exact format:\n"
|
||||
system_prompt = system_prompt .. '```json\n{"tool": "tool_name", "arguments": {...}}\n```\n\n'
|
||||
|
||||
for _, tool in ipairs(tool_definitions) do
|
||||
local name = tool.name or (tool["function"] and tool["function"].name)
|
||||
local desc = tool.description or (tool["function"] and tool["function"].description)
|
||||
if name then
|
||||
system_prompt = system_prompt .. string.format("### %s\n%s\n\n", name, desc or "")
|
||||
end
|
||||
end
|
||||
|
||||
-- Convert messages to Ollama chat format
|
||||
local ollama_messages = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
local content = msg.content
|
||||
if type(content) == "table" then
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(content) do
|
||||
if part.type == "tool_result" then
|
||||
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
|
||||
elseif part.type == "text" then
|
||||
table.insert(text_parts, part.text or "")
|
||||
end
|
||||
end
|
||||
content = table.concat(text_parts, "\n")
|
||||
end
|
||||
table.insert(ollama_messages, { role = msg.role, content = content })
|
||||
end
|
||||
|
||||
local body = {
|
||||
model = get_model(),
|
||||
messages = ollama_messages,
|
||||
system = system_prompt,
|
||||
stream = false,
|
||||
options = {
|
||||
temperature = 0.3,
|
||||
num_predict = 4096,
|
||||
},
|
||||
}
|
||||
|
||||
local host = get_host()
|
||||
local url = host .. "/api/chat"
|
||||
local json_body = vim.json.encode(body)
|
||||
|
||||
local prompt_estimate = logs.estimate_tokens(json_body)
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to Ollama API...")
|
||||
|
||||
local cmd = {
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
url,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-d",
|
||||
json_body,
|
||||
}
|
||||
|
||||
vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if not data or #data == 0 or (data[1] == "" and #data == 1) then
|
||||
return
|
||||
end
|
||||
|
||||
local response_text = table.concat(data, "\n")
|
||||
local ok, response = pcall(vim.json.decode, response_text)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(function()
|
||||
logs.error("Failed to parse Ollama response")
|
||||
callback(nil, "Failed to parse Ollama response")
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
vim.schedule(function()
|
||||
logs.error(response.error or "Ollama API error")
|
||||
callback(nil, response.error or "Ollama API error")
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Log token usage and record cost (Ollama is free but we track usage)
|
||||
if response.prompt_eval_count or response.eval_count then
|
||||
logs.response(response.prompt_eval_count or 0, response.eval_count or 0, "stop")
|
||||
|
||||
-- Record usage for cost tracking (free for local models)
|
||||
local cost = require("codetyper.core.cost")
|
||||
cost.record_usage(
|
||||
get_model(),
|
||||
response.prompt_eval_count or 0,
|
||||
response.eval_count or 0,
|
||||
0 -- No cached tokens for Ollama
|
||||
)
|
||||
end
|
||||
|
||||
-- Parse the response text for tool calls
|
||||
local content_text = response.message and response.message.content or ""
|
||||
local converted = { content = {}, stop_reason = "end_turn" }
|
||||
|
||||
-- Try to extract JSON tool calls from response
|
||||
local json_match = content_text:match("```json%s*(%b{})%s*```")
|
||||
if json_match then
|
||||
local ok_json, parsed = pcall(vim.json.decode, json_match)
|
||||
if ok_json and parsed.tool then
|
||||
table.insert(converted.content, {
|
||||
type = "tool_use",
|
||||
id = "call_" .. string.format("%x", os.time()) .. "_" .. string.format("%x", math.random(0, 0xFFFF)),
|
||||
name = parsed.tool,
|
||||
input = parsed.arguments or {},
|
||||
})
|
||||
logs.thinking("Tool call: " .. parsed.tool)
|
||||
content_text = content_text:gsub("```json.-```", ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
converted.stop_reason = "tool_use"
|
||||
end
|
||||
end
|
||||
|
||||
-- Add text content
|
||||
if content_text and content_text ~= "" then
|
||||
table.insert(converted.content, 1, { type = "text", text = content_text })
|
||||
logs.thinking("Response contains text")
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
callback(converted, nil)
|
||||
end)
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data and #data > 0 and data[1] ~= "" then
|
||||
vim.schedule(function()
|
||||
logs.error("Ollama API request failed: " .. table.concat(data, "\n"))
|
||||
callback(nil, "Ollama API request failed: " .. table.concat(data, "\n"))
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
if code ~= 0 then
|
||||
vim.schedule(function()
|
||||
logs.error("Ollama API request failed with code: " .. code)
|
||||
callback(nil, "Ollama API request failed with code: " .. code)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -158,34 +158,14 @@ end
|
||||
---@param context table Context information
|
||||
---@param callback fun(response: string|nil, error: string|nil) Callback function
|
||||
function M.generate(prompt, context, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local model = get_model()
|
||||
|
||||
-- Log the request
|
||||
logs.request("openai", model)
|
||||
logs.thinking("Building request body...")
|
||||
|
||||
local body = build_request_body(prompt, context)
|
||||
|
||||
-- Estimate prompt tokens
|
||||
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to OpenAI API...")
|
||||
|
||||
utils.notify("Sending request to OpenAI...", vim.log.levels.INFO)
|
||||
|
||||
make_request(body, function(response, err, usage)
|
||||
if err then
|
||||
logs.error(err)
|
||||
utils.notify(err, vim.log.levels.ERROR)
|
||||
callback(nil, err)
|
||||
else
|
||||
-- Log token usage
|
||||
if usage then
|
||||
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
|
||||
end
|
||||
logs.thinking("Response received, extracting code...")
|
||||
logs.info("Code generated successfully")
|
||||
utils.notify("Code generated successfully", vim.log.levels.INFO)
|
||||
callback(response, nil)
|
||||
end
|
||||
@@ -202,174 +182,4 @@ function M.validate()
|
||||
return true
|
||||
end
|
||||
|
||||
--- Generate with tool use support for agentic mode
|
||||
---@param messages table[] Conversation history
|
||||
---@param context table Context information
|
||||
---@param tool_definitions table Tool definitions
|
||||
---@param callback fun(response: table|nil, error: string|nil) Callback with raw response
|
||||
function M.generate_with_tools(messages, context, tool_definitions, callback)
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local model = get_model()
|
||||
|
||||
logs.request("openai", model)
|
||||
logs.thinking("Preparing agent request...")
|
||||
|
||||
local api_key = get_api_key()
|
||||
if not api_key then
|
||||
logs.error("OpenAI API key not configured")
|
||||
callback(nil, "OpenAI API key not configured")
|
||||
return
|
||||
end
|
||||
|
||||
local tools_module = require("codetyper.core.tools")
|
||||
local agent_prompts = require("codetyper.prompts.agents")
|
||||
|
||||
-- Build system prompt with agent instructions
|
||||
local system_prompt = llm.build_system_prompt(context)
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
|
||||
system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions
|
||||
|
||||
-- Format messages for OpenAI
|
||||
local openai_messages = { { role = "system", content = system_prompt } }
|
||||
for _, msg in ipairs(messages) do
|
||||
if type(msg.content) == "string" then
|
||||
table.insert(openai_messages, { role = msg.role, content = msg.content })
|
||||
elseif type(msg.content) == "table" then
|
||||
-- Handle tool results
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(msg.content) do
|
||||
if part.type == "tool_result" then
|
||||
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
|
||||
elseif part.type == "text" then
|
||||
table.insert(text_parts, part.text or "")
|
||||
end
|
||||
end
|
||||
if #text_parts > 0 then
|
||||
table.insert(openai_messages, { role = msg.role, content = table.concat(text_parts, "\n") })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local body = {
|
||||
model = get_model(),
|
||||
messages = openai_messages,
|
||||
max_tokens = 4096,
|
||||
temperature = 0.3,
|
||||
tools = tools_module.to_openai_format(),
|
||||
}
|
||||
|
||||
local endpoint = get_endpoint()
|
||||
local json_body = vim.json.encode(body)
|
||||
|
||||
local prompt_estimate = logs.estimate_tokens(json_body)
|
||||
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
|
||||
logs.thinking("Sending to OpenAI API...")
|
||||
|
||||
local cmd = {
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
endpoint,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-H",
|
||||
"Authorization: Bearer " .. api_key,
|
||||
"-d",
|
||||
json_body,
|
||||
}
|
||||
|
||||
vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if not data or #data == 0 or (data[1] == "" and #data == 1) then
|
||||
return
|
||||
end
|
||||
|
||||
local response_text = table.concat(data, "\n")
|
||||
local ok, response = pcall(vim.json.decode, response_text)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(function()
|
||||
logs.error("Failed to parse OpenAI response")
|
||||
callback(nil, "Failed to parse OpenAI response")
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
vim.schedule(function()
|
||||
logs.error(response.error.message or "OpenAI API error")
|
||||
callback(nil, response.error.message or "OpenAI API error")
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Log token usage and record cost
|
||||
if response.usage then
|
||||
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
|
||||
|
||||
-- Record usage for cost tracking
|
||||
local cost = require("codetyper.core.cost")
|
||||
cost.record_usage(
|
||||
model,
|
||||
response.usage.prompt_tokens or 0,
|
||||
response.usage.completion_tokens or 0,
|
||||
response.usage.prompt_tokens_details and response.usage.prompt_tokens_details.cached_tokens or 0
|
||||
)
|
||||
end
|
||||
|
||||
-- Convert to Claude-like format for parser compatibility
|
||||
local converted = { content = {} }
|
||||
if response.choices and response.choices[1] then
|
||||
local choice = response.choices[1]
|
||||
if choice.message then
|
||||
if choice.message.content then
|
||||
table.insert(converted.content, { type = "text", text = choice.message.content })
|
||||
logs.thinking("Response contains text")
|
||||
end
|
||||
if choice.message.tool_calls then
|
||||
for _, tc in ipairs(choice.message.tool_calls) do
|
||||
local args = {}
|
||||
if tc["function"] and tc["function"].arguments then
|
||||
local ok_args, parsed = pcall(vim.json.decode, tc["function"].arguments)
|
||||
if ok_args then
|
||||
args = parsed
|
||||
end
|
||||
end
|
||||
table.insert(converted.content, {
|
||||
type = "tool_use",
|
||||
id = tc.id,
|
||||
name = tc["function"].name,
|
||||
input = args,
|
||||
})
|
||||
logs.thinking("Tool call: " .. tc["function"].name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
callback(converted, nil)
|
||||
end)
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data and #data > 0 and data[1] ~= "" then
|
||||
vim.schedule(function()
|
||||
logs.error("OpenAI API request failed: " .. table.concat(data, "\n"))
|
||||
callback(nil, "OpenAI API request failed: " .. table.concat(data, "\n"))
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
if code ~= 0 then
|
||||
vim.schedule(function()
|
||||
logs.error("OpenAI API request failed with code: " .. code)
|
||||
callback(nil, "OpenAI API request failed with code: " .. code)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
---@mod codetyper.agent.parser Response parser for agent tool calls
|
||||
---
|
||||
--- Parses LLM responses to extract tool calls from both Claude and Ollama.
|
||||
|
||||
local M = {}
|
||||
|
||||
local params = require("codetyper.params.agents.parser")
|
||||
|
||||
|
||||
---@class ParsedResponse
|
||||
---@field text string Text content from the response
|
||||
---@field tool_calls ToolCall[] List of tool calls
|
||||
---@field stop_reason string Reason the response stopped
|
||||
|
||||
---@class ToolCall
|
||||
---@field id string Unique identifier for the tool call
|
||||
---@field name string Name of the tool to call
|
||||
---@field parameters table Parameters for the tool
|
||||
|
||||
--- Parse Claude API response for tool_use blocks
|
||||
---@param response table Raw Claude API response
|
||||
---@return ParsedResponse
|
||||
function M.parse_claude_response(response)
|
||||
local result = {
|
||||
text = "",
|
||||
tool_calls = {},
|
||||
stop_reason = response.stop_reason or "end_turn",
|
||||
}
|
||||
|
||||
if response.content then
|
||||
for _, block in ipairs(response.content) do
|
||||
if block.type == "text" then
|
||||
result.text = result.text .. (block.text or "")
|
||||
elseif block.type == "tool_use" then
|
||||
table.insert(result.tool_calls, {
|
||||
id = block.id,
|
||||
name = block.name,
|
||||
parameters = block.input or {},
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Parse Ollama response for JSON tool blocks
|
||||
---@param response_text string Raw text response from Ollama
|
||||
---@return ParsedResponse
|
||||
function M.parse_ollama_response(response_text)
|
||||
local result = {
|
||||
text = response_text,
|
||||
tool_calls = {},
|
||||
stop_reason = params.defaults.stop_reason,
|
||||
}
|
||||
|
||||
-- Pattern to find JSON tool blocks in fenced code blocks
|
||||
local fenced_pattern = params.patterns.fenced_json
|
||||
|
||||
-- Find all fenced JSON blocks
|
||||
for json_str in response_text:gmatch(fenced_pattern) do
|
||||
local ok, parsed = pcall(vim.json.decode, json_str)
|
||||
if ok and parsed.tool and parsed.parameters then
|
||||
table.insert(result.tool_calls, {
|
||||
id = string.format("%d_%d", os.time(), math.random(10000)),
|
||||
name = parsed.tool,
|
||||
parameters = parsed.parameters,
|
||||
})
|
||||
result.stop_reason = params.defaults.tool_stop_reason
|
||||
end
|
||||
end
|
||||
|
||||
-- Also try to find inline JSON (not in code blocks)
|
||||
-- Pattern for {"tool": "...", "parameters": {...}}
|
||||
if #result.tool_calls == 0 then
|
||||
local inline_pattern = params.patterns.inline_json
|
||||
for json_str in response_text:gmatch(inline_pattern) do
|
||||
local ok, parsed = pcall(vim.json.decode, json_str)
|
||||
if ok and parsed.tool and parsed.parameters then
|
||||
table.insert(result.tool_calls, {
|
||||
id = string.format("%d_%d", os.time(), math.random(10000)),
|
||||
name = parsed.tool,
|
||||
parameters = parsed.parameters,
|
||||
})
|
||||
result.stop_reason = params.defaults.tool_stop_reason
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Clean tool JSON from displayed text
|
||||
if #result.tool_calls > 0 then
|
||||
result.text = result.text:gsub(params.patterns.fenced_json, params.defaults.replacement_text)
|
||||
result.text = result.text:gsub(params.patterns.inline_json, params.defaults.replacement_text)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Check if response contains tool calls
|
||||
---@param parsed ParsedResponse Parsed response
|
||||
---@return boolean
|
||||
function M.has_tool_calls(parsed)
|
||||
return #parsed.tool_calls > 0
|
||||
end
|
||||
|
||||
--- Extract just the text content, removing tool-related markup
|
||||
---@param text string Response text
|
||||
---@return string Cleaned text
|
||||
function M.clean_text(text)
|
||||
local cleaned = text
|
||||
-- Remove tool JSON blocks
|
||||
cleaned = cleaned:gsub("```json%s*%b{}%s*```", "")
|
||||
cleaned = cleaned:gsub('%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%}', "")
|
||||
-- Clean up extra whitespace
|
||||
cleaned = cleaned:gsub("\n\n\n+", "\n\n")
|
||||
cleaned = cleaned:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return cleaned
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,616 +0,0 @@
|
||||
---@mod codetyper.agent.executor Tool executor for agent system
|
||||
---
|
||||
--- Executes tools requested by the LLM and returns results.
|
||||
|
||||
local M = {}
|
||||
local utils = require("codetyper.support.utils")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
---@class ExecutionResult
|
||||
---@field success boolean Whether the execution succeeded
|
||||
---@field result string Result message or content
|
||||
---@field requires_approval boolean Whether user approval is needed
|
||||
---@field diff_data? DiffData Data for diff preview (if requires_approval)
|
||||
|
||||
--- Open a file in a buffer (in a non-agent window)
|
||||
---@param path string File path to open
|
||||
---@param jump_to_line? number Optional line number to jump to
|
||||
local function open_file_in_buffer(path, jump_to_line)
|
||||
if not path or path == "" then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check if file exists
|
||||
if vim.fn.filereadable(path) ~= 1 then
|
||||
return
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
-- Find a suitable window (not the agent UI windows)
|
||||
local target_win = nil
|
||||
local agent_ui_ok, agent_ui = pcall(require, "codetyper.agent.ui")
|
||||
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
local buftype = vim.bo[buf].buftype
|
||||
|
||||
-- Skip special buffers (agent UI, nofile, etc.)
|
||||
if buftype == "" or buftype == "acwrite" then
|
||||
-- Check if this is not an agent UI window
|
||||
local is_agent_win = false
|
||||
if agent_ui_ok and agent_ui.is_open() then
|
||||
-- Skip agent windows by checking if it's one of our special buffers
|
||||
local bufname = vim.api.nvim_buf_get_name(buf)
|
||||
if bufname == "" then
|
||||
-- Could be agent buffer, check by buffer option
|
||||
is_agent_win = vim.bo[buf].buftype == "nofile"
|
||||
end
|
||||
end
|
||||
|
||||
if not is_agent_win then
|
||||
target_win = win
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- If no suitable window found, create a new split
|
||||
if not target_win then
|
||||
-- Get the rightmost non-agent window or create one
|
||||
vim.cmd("rightbelow vsplit")
|
||||
target_win = vim.api.nvim_get_current_win()
|
||||
end
|
||||
|
||||
-- Open the file in the target window
|
||||
vim.api.nvim_set_current_win(target_win)
|
||||
vim.cmd("edit " .. vim.fn.fnameescape(path))
|
||||
|
||||
-- Jump to line if specified
|
||||
if jump_to_line and jump_to_line > 0 then
|
||||
local line_count = vim.api.nvim_buf_line_count(0)
|
||||
local target_line = math.min(jump_to_line, line_count)
|
||||
vim.api.nvim_win_set_cursor(target_win, { target_line, 0 })
|
||||
vim.cmd("normal! zz")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Expose open_file_in_buffer for external use
|
||||
M.open_file_in_buffer = open_file_in_buffer
|
||||
|
||||
---@class DiffData
|
||||
---@field path string File path
|
||||
---@field original string Original content
|
||||
---@field modified string Modified content
|
||||
---@field operation string Operation type: "edit", "create", "overwrite", "bash"
|
||||
|
||||
--- Execute a tool and return result via callback
|
||||
---@param tool_name string Name of the tool to execute
|
||||
---@param parameters table Tool parameters
|
||||
---@param callback fun(result: ExecutionResult) Callback with result
|
||||
function M.execute(tool_name, parameters, callback)
|
||||
local handlers = {
|
||||
read_file = M.handle_read_file,
|
||||
edit_file = M.handle_edit_file,
|
||||
write_file = M.handle_write_file,
|
||||
bash = M.handle_bash,
|
||||
delete_file = M.handle_delete_file,
|
||||
list_directory = M.handle_list_directory,
|
||||
search_files = M.handle_search_files,
|
||||
}
|
||||
|
||||
local handler = handlers[tool_name]
|
||||
if not handler then
|
||||
callback({
|
||||
success = false,
|
||||
result = "Unknown tool: " .. tool_name,
|
||||
requires_approval = false,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
handler(parameters, callback)
|
||||
end
|
||||
|
||||
--- Handle read_file tool
|
||||
---@param params table { path: string }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_read_file(params, callback)
|
||||
local path = M.resolve_path(params.path)
|
||||
|
||||
-- Log the read operation in Claude Code style
|
||||
local relative_path = vim.fn.fnamemodify(path, ":~:.")
|
||||
logs.read(relative_path)
|
||||
|
||||
local content = utils.read_file(path)
|
||||
|
||||
if content then
|
||||
-- Log how many lines were read
|
||||
local lines = vim.split(content, "\n", { plain = true })
|
||||
logs.add({ type = "result", message = string.format(" ⎿ Read %d lines", #lines) })
|
||||
|
||||
-- Open the file in a buffer so user can see it
|
||||
open_file_in_buffer(path)
|
||||
|
||||
callback({
|
||||
success = true,
|
||||
result = content,
|
||||
requires_approval = false,
|
||||
})
|
||||
else
|
||||
logs.add({ type = "error", message = " ⎿ File not found" })
|
||||
callback({
|
||||
success = false,
|
||||
result = "Could not read file: " .. path,
|
||||
requires_approval = false,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle edit_file tool
|
||||
---@param params table { path: string, find: string, replace: string }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_edit_file(params, callback)
|
||||
local path = M.resolve_path(params.path)
|
||||
local relative_path = vim.fn.fnamemodify(path, ":~:.")
|
||||
|
||||
-- Log the edit operation
|
||||
logs.add({ type = "action", message = string.format("Edit(%s)", relative_path) })
|
||||
|
||||
local original = utils.read_file(path)
|
||||
|
||||
if not original then
|
||||
logs.add({ type = "error", message = " ⎿ File not found" })
|
||||
callback({
|
||||
success = false,
|
||||
result = "File not found: " .. path,
|
||||
requires_approval = false,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
-- Try to find and replace the content
|
||||
local escaped_find = utils.escape_pattern(params.find)
|
||||
local new_content, count = original:gsub(escaped_find, params.replace, 1)
|
||||
|
||||
if count == 0 then
|
||||
logs.add({ type = "error", message = " ⎿ Content not found" })
|
||||
callback({
|
||||
success = false,
|
||||
result = "Could not find content to replace in: " .. path,
|
||||
requires_approval = false,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate lines changed
|
||||
local original_lines = #vim.split(original, "\n", { plain = true })
|
||||
local new_lines = #vim.split(new_content, "\n", { plain = true })
|
||||
local diff = new_lines - original_lines
|
||||
if diff > 0 then
|
||||
logs.add({ type = "result", message = string.format(" ⎿ +%d lines (pending approval)", diff) })
|
||||
elseif diff < 0 then
|
||||
logs.add({ type = "result", message = string.format(" ⎿ %d lines (pending approval)", diff) })
|
||||
else
|
||||
logs.add({ type = "result", message = " ⎿ Modified (pending approval)" })
|
||||
end
|
||||
|
||||
-- Requires user approval - show diff
|
||||
callback({
|
||||
success = true,
|
||||
result = "Edit prepared for: " .. path,
|
||||
requires_approval = true,
|
||||
diff_data = {
|
||||
path = path,
|
||||
original = original,
|
||||
modified = new_content,
|
||||
operation = "edit",
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
--- Handle write_file tool
|
||||
---@param params table { path: string, content: string }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_write_file(params, callback)
|
||||
local path = M.resolve_path(params.path)
|
||||
local relative_path = vim.fn.fnamemodify(path, ":~:.")
|
||||
local original = utils.read_file(path) or ""
|
||||
local operation = original == "" and "create" or "overwrite"
|
||||
|
||||
-- Log the write operation
|
||||
if operation == "create" then
|
||||
logs.add({ type = "action", message = string.format("Write(%s)", relative_path) })
|
||||
local new_lines = #vim.split(params.content, "\n", { plain = true })
|
||||
logs.add({ type = "result", message = string.format(" ⎿ New file (%d lines, pending approval)", new_lines) })
|
||||
else
|
||||
logs.add({ type = "action", message = string.format("Update(%s)", relative_path) })
|
||||
local original_lines = #vim.split(original, "\n", { plain = true })
|
||||
local new_lines = #vim.split(params.content, "\n", { plain = true })
|
||||
local diff = new_lines - original_lines
|
||||
if diff > 0 then
|
||||
logs.add({ type = "result", message = string.format(" ⎿ +%d lines (pending approval)", diff) })
|
||||
elseif diff < 0 then
|
||||
logs.add({ type = "result", message = string.format(" ⎿ %d lines (pending approval)", diff) })
|
||||
else
|
||||
logs.add({ type = "result", message = " ⎿ Modified (pending approval)" })
|
||||
end
|
||||
end
|
||||
|
||||
-- Ensure parent directory exists
|
||||
local dir = vim.fn.fnamemodify(path, ":h")
|
||||
if dir ~= "" and dir ~= "." then
|
||||
utils.ensure_dir(dir)
|
||||
end
|
||||
|
||||
callback({
|
||||
success = true,
|
||||
result = (operation == "create" and "Create" or "Overwrite") .. " prepared for: " .. path,
|
||||
requires_approval = true,
|
||||
diff_data = {
|
||||
path = path,
|
||||
original = original,
|
||||
modified = params.content,
|
||||
operation = operation,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
--- Handle bash tool
|
||||
---@param params table { command: string, timeout?: number }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_bash(params, callback)
|
||||
local command = params.command
|
||||
|
||||
-- Log the bash operation
|
||||
logs.add({ type = "action", message = string.format("Bash(%s)", command:sub(1, 50) .. (#command > 50 and "..." or "")) })
|
||||
logs.add({ type = "result", message = " ⎿ Pending approval" })
|
||||
|
||||
-- Requires user approval first
|
||||
callback({
|
||||
success = true,
|
||||
result = "Command: " .. command,
|
||||
requires_approval = true,
|
||||
diff_data = {
|
||||
path = "[bash]",
|
||||
original = "",
|
||||
modified = "$ " .. command,
|
||||
operation = "bash",
|
||||
},
|
||||
bash_command = command,
|
||||
bash_timeout = params.timeout or 30000,
|
||||
})
|
||||
end
|
||||
|
||||
--- Handle delete_file tool
|
||||
---@param params table { path: string, reason: string }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_delete_file(params, callback)
|
||||
local path = M.resolve_path(params.path)
|
||||
local reason = params.reason or "No reason provided"
|
||||
|
||||
-- Check if file exists
|
||||
if not utils.file_exists(path) then
|
||||
callback({
|
||||
success = false,
|
||||
result = "File not found: " .. path,
|
||||
requires_approval = false,
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
-- Read content for showing in diff (so user knows what they're deleting)
|
||||
local content = utils.read_file(path) or "[Could not read file]"
|
||||
|
||||
callback({
|
||||
success = true,
|
||||
result = "Delete: " .. path .. " (" .. reason .. ")",
|
||||
requires_approval = true,
|
||||
diff_data = {
|
||||
path = path,
|
||||
original = content,
|
||||
modified = "", -- Empty = deletion
|
||||
operation = "delete",
|
||||
reason = reason,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
--- Handle list_directory tool
|
||||
---@param params table { path?: string, recursive?: boolean }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_list_directory(params, callback)
|
||||
local path = params.path and M.resolve_path(params.path) or (utils.get_project_root() or vim.fn.getcwd())
|
||||
local recursive = params.recursive or false
|
||||
|
||||
-- Use vim.fn.readdir or glob for directory listing
|
||||
local entries = {}
|
||||
local function list_dir(dir, depth)
|
||||
if depth > 3 then
|
||||
return
|
||||
end
|
||||
|
||||
local ok, files = pcall(vim.fn.readdir, dir)
|
||||
if not ok or not files then
|
||||
return
|
||||
end
|
||||
|
||||
for _, name in ipairs(files) do
|
||||
if name ~= "." and name ~= ".." and not name:match("^%.git$") and not name:match("^node_modules$") then
|
||||
local full_path = dir .. "/" .. name
|
||||
local stat = vim.loop.fs_stat(full_path)
|
||||
if stat then
|
||||
local prefix = string.rep(" ", depth)
|
||||
local type_indicator = stat.type == "directory" and "/" or ""
|
||||
table.insert(entries, prefix .. name .. type_indicator)
|
||||
|
||||
if recursive and stat.type == "directory" then
|
||||
list_dir(full_path, depth + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
list_dir(path, 0)
|
||||
|
||||
local result = "Directory: " .. path .. "\n\n" .. table.concat(entries, "\n")
|
||||
|
||||
callback({
|
||||
success = true,
|
||||
result = result,
|
||||
requires_approval = false,
|
||||
})
|
||||
end
|
||||
|
||||
--- Handle search_files tool
|
||||
---@param params table { pattern?: string, content?: string, path?: string }
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.handle_search_files(params, callback)
|
||||
local search_path = params.path and M.resolve_path(params.path) or (utils.get_project_root() or vim.fn.getcwd())
|
||||
local pattern = params.pattern
|
||||
local content_search = params.content
|
||||
|
||||
local results = {}
|
||||
|
||||
if pattern then
|
||||
-- Search by file name pattern using glob
|
||||
local glob_pattern = search_path .. "/**/" .. pattern
|
||||
local files = vim.fn.glob(glob_pattern, false, true)
|
||||
|
||||
for _, file in ipairs(files) do
|
||||
-- Skip common ignore patterns
|
||||
if not file:match("node_modules") and not file:match("%.git/") then
|
||||
local relative = file:gsub(search_path .. "/", "")
|
||||
table.insert(results, relative)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if content_search then
|
||||
-- Search by content using grep
|
||||
local grep_results = {}
|
||||
local grep_cmd = string.format("grep -rl '%s' '%s' 2>/dev/null | head -20", content_search:gsub("'", "\\'"), search_path)
|
||||
|
||||
local handle = io.popen(grep_cmd)
|
||||
if handle then
|
||||
for line in handle:lines() do
|
||||
if not line:match("node_modules") and not line:match("%.git/") then
|
||||
local relative = line:gsub(search_path .. "/", "")
|
||||
table.insert(grep_results, relative)
|
||||
end
|
||||
end
|
||||
handle:close()
|
||||
end
|
||||
|
||||
-- Merge with pattern results or use as primary results
|
||||
if #results == 0 then
|
||||
results = grep_results
|
||||
else
|
||||
-- Intersection of pattern and content results
|
||||
local pattern_set = {}
|
||||
for _, f in ipairs(results) do
|
||||
pattern_set[f] = true
|
||||
end
|
||||
results = {}
|
||||
for _, f in ipairs(grep_results) do
|
||||
if pattern_set[f] then
|
||||
table.insert(results, f)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local result_text = "Search results"
|
||||
if pattern then
|
||||
result_text = result_text .. " (pattern: " .. pattern .. ")"
|
||||
end
|
||||
if content_search then
|
||||
result_text = result_text .. " (content: " .. content_search .. ")"
|
||||
end
|
||||
result_text = result_text .. ":\n\n"
|
||||
|
||||
if #results == 0 then
|
||||
result_text = result_text .. "No files found."
|
||||
else
|
||||
result_text = result_text .. table.concat(results, "\n")
|
||||
end
|
||||
|
||||
callback({
|
||||
success = true,
|
||||
result = result_text,
|
||||
requires_approval = false,
|
||||
})
|
||||
end
|
||||
|
||||
--- Actually apply an approved change
|
||||
---@param diff_data DiffData The diff data to apply
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.apply_change(diff_data, callback)
|
||||
if diff_data.operation == "bash" then
|
||||
-- Extract command from modified (remove "$ " prefix)
|
||||
local command = diff_data.modified:gsub("^%$ ", "")
|
||||
M.execute_bash_command(command, 30000, callback)
|
||||
elseif diff_data.operation == "delete" then
|
||||
-- Delete file
|
||||
local ok, err = os.remove(diff_data.path)
|
||||
if ok then
|
||||
-- Close buffer if it's open
|
||||
M.close_buffer_if_open(diff_data.path)
|
||||
callback({
|
||||
success = true,
|
||||
result = "Deleted: " .. diff_data.path,
|
||||
requires_approval = false,
|
||||
})
|
||||
else
|
||||
callback({
|
||||
success = false,
|
||||
result = "Failed to delete: " .. diff_data.path .. " (" .. (err or "unknown error") .. ")",
|
||||
requires_approval = false,
|
||||
})
|
||||
end
|
||||
else
|
||||
-- Write file
|
||||
local success = utils.write_file(diff_data.path, diff_data.modified)
|
||||
if success then
|
||||
-- Open and/or reload buffer so user can see the changes
|
||||
open_file_in_buffer(diff_data.path)
|
||||
M.reload_buffer_if_open(diff_data.path)
|
||||
callback({
|
||||
success = true,
|
||||
result = "Changes applied to: " .. diff_data.path,
|
||||
requires_approval = false,
|
||||
})
|
||||
else
|
||||
callback({
|
||||
success = false,
|
||||
result = "Failed to write: " .. diff_data.path,
|
||||
requires_approval = false,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Execute a bash command
|
||||
---@param command string Command to execute
|
||||
---@param timeout number Timeout in milliseconds
|
||||
---@param callback fun(result: ExecutionResult)
|
||||
function M.execute_bash_command(command, timeout, callback)
|
||||
local stdout_data = {}
|
||||
local stderr_data = {}
|
||||
local job_id
|
||||
|
||||
job_id = vim.fn.jobstart(command, {
|
||||
stdout_buffered = true,
|
||||
stderr_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if data then
|
||||
for _, line in ipairs(data) do
|
||||
if line ~= "" then
|
||||
table.insert(stdout_data, line)
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data then
|
||||
for _, line in ipairs(data) do
|
||||
if line ~= "" then
|
||||
table.insert(stderr_data, line)
|
||||
end
|
||||
end
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, exit_code)
|
||||
vim.schedule(function()
|
||||
local result = table.concat(stdout_data, "\n")
|
||||
if #stderr_data > 0 then
|
||||
if result ~= "" then
|
||||
result = result .. "\n"
|
||||
end
|
||||
result = result .. "STDERR:\n" .. table.concat(stderr_data, "\n")
|
||||
end
|
||||
result = result .. "\n[Exit code: " .. exit_code .. "]"
|
||||
|
||||
callback({
|
||||
success = exit_code == 0,
|
||||
result = result,
|
||||
requires_approval = false,
|
||||
})
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
-- Set up timeout
|
||||
if job_id > 0 then
|
||||
vim.defer_fn(function()
|
||||
if vim.fn.jobwait({ job_id }, 0)[1] == -1 then
|
||||
vim.fn.jobstop(job_id)
|
||||
vim.schedule(function()
|
||||
callback({
|
||||
success = false,
|
||||
result = "Command timed out after " .. timeout .. "ms",
|
||||
requires_approval = false,
|
||||
})
|
||||
end)
|
||||
end
|
||||
end, timeout)
|
||||
else
|
||||
callback({
|
||||
success = false,
|
||||
result = "Failed to start command",
|
||||
requires_approval = false,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
--- Reload a buffer if it's currently open
|
||||
---@param filepath string Path to the file
|
||||
function M.reload_buffer_if_open(filepath)
|
||||
local full_path = vim.fn.fnamemodify(filepath, ":p")
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_is_loaded(buf) then
|
||||
local buf_name = vim.api.nvim_buf_get_name(buf)
|
||||
if buf_name == full_path then
|
||||
vim.api.nvim_buf_call(buf, function()
|
||||
vim.cmd("edit!")
|
||||
end)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Close a buffer if it's currently open (for deleted files)
|
||||
---@param filepath string Path to the file
|
||||
function M.close_buffer_if_open(filepath)
|
||||
local full_path = vim.fn.fnamemodify(filepath, ":p")
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.api.nvim_buf_is_loaded(buf) then
|
||||
local buf_name = vim.api.nvim_buf_get_name(buf)
|
||||
if buf_name == full_path then
|
||||
-- Force close the buffer
|
||||
pcall(vim.api.nvim_buf_delete, buf, { force = true })
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve a path (expand ~ and make absolute if needed)
|
||||
---@param path string Path to resolve
|
||||
---@return string Resolved path
|
||||
function M.resolve_path(path)
|
||||
-- Expand ~ to home directory
|
||||
local expanded = vim.fn.expand(path)
|
||||
|
||||
-- If relative, make it relative to project root or cwd
|
||||
if not vim.startswith(expanded, "/") then
|
||||
local root = utils.get_project_root() or vim.fn.getcwd()
|
||||
expanded = root .. "/" .. expanded
|
||||
end
|
||||
|
||||
return vim.fn.fnamemodify(expanded, ":p")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,381 +0,0 @@
|
||||
---@mod codetyper.agent.loop Agent loop with tool orchestration
|
||||
---@brief [[
|
||||
--- Main agent loop that handles multi-turn conversations with tool use.
|
||||
--- Agent execution loop with tool calling support.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local prompts = require("codetyper.prompts.agents.loop")
|
||||
|
||||
---@class AgentMessage
|
||||
---@field role "system"|"user"|"assistant"|"tool"
|
||||
---@field content string|table
|
||||
---@field tool_call_id? string For tool responses
|
||||
---@field tool_calls? table[] For assistant tool calls
|
||||
---@field name? string Tool name for tool responses
|
||||
|
||||
---@class AgentLoopOpts
|
||||
---@field system_prompt string System prompt
|
||||
---@field user_input string Initial user message
|
||||
---@field tools? CoderTool[] Available tools (default: all registered)
|
||||
---@field max_iterations? number Max tool call iterations (default: 10)
|
||||
---@field provider? string LLM provider to use
|
||||
---@field on_start? fun() Called when loop starts
|
||||
---@field on_chunk? fun(chunk: string) Called for each response chunk
|
||||
---@field on_tool_call? fun(name: string, input: table) Called before tool execution
|
||||
---@field on_tool_result? fun(name: string, result: any, error: string|nil) Called after tool execution
|
||||
---@field on_message? fun(message: AgentMessage) Called for each message added
|
||||
---@field on_complete? fun(result: string|nil, error: string|nil) Called when loop completes
|
||||
---@field session_ctx? table Session context shared across tools
|
||||
|
||||
--- Format tool definitions for OpenAI-compatible API
|
||||
---@param tools CoderTool[]
|
||||
---@return table[]
|
||||
local function format_tools_for_api(tools)
|
||||
local formatted = {}
|
||||
for _, tool in ipairs(tools) do
|
||||
local properties = {}
|
||||
local required = {}
|
||||
|
||||
for _, param in ipairs(tool.params or {}) do
|
||||
properties[param.name] = {
|
||||
type = param.type == "integer" and "number" or param.type,
|
||||
description = param.description,
|
||||
}
|
||||
if not param.optional then
|
||||
table.insert(required, param.name)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(formatted, {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = tool.name,
|
||||
description = type(tool.description) == "function" and tool.description() or tool.description,
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
},
|
||||
})
|
||||
end
|
||||
return formatted
|
||||
end
|
||||
|
||||
--- Parse tool calls from LLM response
|
||||
---@param response table LLM response
|
||||
---@return table[] tool_calls
|
||||
local function parse_tool_calls(response)
|
||||
local tool_calls = {}
|
||||
|
||||
-- Handle different response formats
|
||||
if response.tool_calls then
|
||||
-- OpenAI format
|
||||
for _, call in ipairs(response.tool_calls) do
|
||||
local args = call["function"].arguments
|
||||
if type(args) == "string" then
|
||||
local ok, parsed = pcall(vim.json.decode, args)
|
||||
if ok then
|
||||
args = parsed
|
||||
end
|
||||
end
|
||||
table.insert(tool_calls, {
|
||||
id = call.id,
|
||||
name = call["function"].name,
|
||||
input = args,
|
||||
})
|
||||
end
|
||||
elseif response.content and type(response.content) == "table" then
|
||||
-- Claude format (content blocks)
|
||||
for _, block in ipairs(response.content) do
|
||||
if block.type == "tool_use" then
|
||||
table.insert(tool_calls, {
|
||||
id = block.id,
|
||||
name = block.name,
|
||||
input = block.input,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return tool_calls
|
||||
end
|
||||
|
||||
--- Build messages for LLM request
|
||||
---@param history AgentMessage[]
|
||||
---@return table[]
|
||||
local function build_messages(history)
|
||||
local messages = {}
|
||||
|
||||
for _, msg in ipairs(history) do
|
||||
if msg.role == "system" then
|
||||
table.insert(messages, {
|
||||
role = "system",
|
||||
content = msg.content,
|
||||
})
|
||||
elseif msg.role == "user" then
|
||||
table.insert(messages, {
|
||||
role = "user",
|
||||
content = msg.content,
|
||||
})
|
||||
elseif msg.role == "assistant" then
|
||||
local message = {
|
||||
role = "assistant",
|
||||
content = msg.content,
|
||||
}
|
||||
if msg.tool_calls then
|
||||
message.tool_calls = msg.tool_calls
|
||||
end
|
||||
table.insert(messages, message)
|
||||
elseif msg.role == "tool" then
|
||||
table.insert(messages, {
|
||||
role = "tool",
|
||||
tool_call_id = msg.tool_call_id,
|
||||
content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return messages
|
||||
end
|
||||
|
||||
--- Execute the agent loop
|
||||
---@param opts AgentLoopOpts
|
||||
function M.run(opts)
|
||||
local tools_mod = require("codetyper.core.tools")
|
||||
local llm = require("codetyper.core.llm")
|
||||
|
||||
-- Get tools
|
||||
local tools = opts.tools or tools_mod.list()
|
||||
local tool_map = {}
|
||||
for _, tool in ipairs(tools) do
|
||||
tool_map[tool.name] = tool
|
||||
end
|
||||
|
||||
-- Initialize conversation history
|
||||
---@type AgentMessage[]
|
||||
local history = {
|
||||
{ role = "system", content = opts.system_prompt },
|
||||
{ role = "user", content = opts.user_input },
|
||||
}
|
||||
|
||||
local session_ctx = opts.session_ctx or {}
|
||||
local max_iterations = opts.max_iterations or 10
|
||||
local iteration = 0
|
||||
|
||||
-- Callback wrappers
|
||||
local function on_message(msg)
|
||||
if opts.on_message then
|
||||
opts.on_message(msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- Notify of initial messages
|
||||
for _, msg in ipairs(history) do
|
||||
on_message(msg)
|
||||
end
|
||||
|
||||
-- Start notification
|
||||
if opts.on_start then
|
||||
opts.on_start()
|
||||
end
|
||||
|
||||
--- Process one iteration of the loop
|
||||
local function process_iteration()
|
||||
iteration = iteration + 1
|
||||
|
||||
if iteration > max_iterations then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, "Max iterations reached")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Build request
|
||||
local messages = build_messages(history)
|
||||
local formatted_tools = format_tools_for_api(tools)
|
||||
|
||||
-- Build context for LLM
|
||||
local context = {
|
||||
file_content = "",
|
||||
language = "lua",
|
||||
extension = "lua",
|
||||
prompt_type = "agent",
|
||||
tools = formatted_tools,
|
||||
}
|
||||
|
||||
-- Get LLM response
|
||||
local client = llm.get_client()
|
||||
if not client then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, "No LLM client available")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Build prompt from messages
|
||||
local prompt_parts = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg.role ~= "system" then
|
||||
table.insert(prompt_parts, string.format("[%s]: %s", msg.role, msg.content or ""))
|
||||
end
|
||||
end
|
||||
local prompt = table.concat(prompt_parts, "\n\n")
|
||||
|
||||
client.generate(prompt, context, function(response, error)
|
||||
if error then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, error)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Chunk callback
|
||||
if opts.on_chunk then
|
||||
opts.on_chunk(response)
|
||||
end
|
||||
|
||||
-- Parse response for tool calls
|
||||
-- For now, we'll use a simple heuristic to detect tool calls in the response
|
||||
-- In a full implementation, the LLM would return structured tool calls
|
||||
local tool_calls = {}
|
||||
|
||||
-- Try to parse JSON tool calls from response
|
||||
local json_match = response:match("```json%s*(%b{})%s*```")
|
||||
if json_match then
|
||||
local ok, parsed = pcall(vim.json.decode, json_match)
|
||||
if ok and parsed.tool_calls then
|
||||
tool_calls = parsed.tool_calls
|
||||
end
|
||||
end
|
||||
|
||||
-- Add assistant message
|
||||
local assistant_msg = {
|
||||
role = "assistant",
|
||||
content = response,
|
||||
tool_calls = #tool_calls > 0 and tool_calls or nil,
|
||||
}
|
||||
table.insert(history, assistant_msg)
|
||||
on_message(assistant_msg)
|
||||
|
||||
-- Process tool calls
|
||||
if #tool_calls > 0 then
|
||||
local pending = #tool_calls
|
||||
local results = {}
|
||||
|
||||
for i, call in ipairs(tool_calls) do
|
||||
local tool = tool_map[call.name]
|
||||
if not tool then
|
||||
results[i] = { error = "Unknown tool: " .. call.name }
|
||||
pending = pending - 1
|
||||
else
|
||||
-- Notify of tool call
|
||||
if opts.on_tool_call then
|
||||
opts.on_tool_call(call.name, call.input)
|
||||
end
|
||||
|
||||
-- Execute tool
|
||||
local tool_opts = {
|
||||
on_log = function(msg)
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({ type = "tool", message = msg })
|
||||
end)
|
||||
end,
|
||||
on_complete = function(result, err)
|
||||
results[i] = { result = result, error = err }
|
||||
pending = pending - 1
|
||||
|
||||
-- Notify of tool result
|
||||
if opts.on_tool_result then
|
||||
opts.on_tool_result(call.name, result, err)
|
||||
end
|
||||
|
||||
-- Add tool response to history
|
||||
local tool_msg = {
|
||||
role = "tool",
|
||||
tool_call_id = call.id or tostring(i),
|
||||
name = call.name,
|
||||
content = err or result,
|
||||
}
|
||||
table.insert(history, tool_msg)
|
||||
on_message(tool_msg)
|
||||
|
||||
-- Continue loop when all tools complete
|
||||
if pending == 0 then
|
||||
vim.schedule(process_iteration)
|
||||
end
|
||||
end,
|
||||
session_ctx = session_ctx,
|
||||
}
|
||||
|
||||
-- Validate and execute
|
||||
local valid, validation_err = true, nil
|
||||
if tool.validate_input then
|
||||
valid, validation_err = tool:validate_input(call.input)
|
||||
end
|
||||
|
||||
if not valid then
|
||||
tool_opts.on_complete(nil, validation_err)
|
||||
else
|
||||
local result, err = tool.func(call.input, tool_opts)
|
||||
-- If sync result, call on_complete
|
||||
if result ~= nil or err ~= nil then
|
||||
tool_opts.on_complete(result, err)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- No tool calls - loop complete
|
||||
if opts.on_complete then
|
||||
opts.on_complete(response, nil)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Start the loop
|
||||
process_iteration()
|
||||
end
|
||||
|
||||
--- Create an agent with default settings
|
||||
---@param task string Task description
|
||||
---@param opts? AgentLoopOpts Additional options
|
||||
function M.create(task, opts)
|
||||
opts = opts or {}
|
||||
|
||||
local system_prompt = opts.system_prompt or prompts.default_system_prompt
|
||||
|
||||
M.run(vim.tbl_extend("force", opts, {
|
||||
system_prompt = system_prompt,
|
||||
user_input = task,
|
||||
}))
|
||||
end
|
||||
|
||||
--- Simple dispatch agent for sub-tasks
|
||||
---@param prompt string Task for the sub-agent
|
||||
---@param on_complete fun(result: string|nil, error: string|nil) Completion callback
|
||||
---@param opts? table Additional options
|
||||
function M.dispatch(prompt, on_complete, opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- Sub-agents get limited tools by default
|
||||
local tools_mod = require("codetyper.core.tools")
|
||||
local safe_tools = tools_mod.list(function(tool)
|
||||
return tool.name == "view" or tool.name == "grep" or tool.name == "glob"
|
||||
end)
|
||||
|
||||
M.run({
|
||||
system_prompt = prompts.dispatch_prompt,
|
||||
user_input = prompt,
|
||||
tools = opts.tools or safe_tools,
|
||||
max_iterations = opts.max_iterations or 5,
|
||||
on_complete = on_complete,
|
||||
session_ctx = opts.session_ctx,
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,155 +0,0 @@
|
||||
---@mod codetyper.agent.resume Resume context for agent sessions
|
||||
---
|
||||
--- Saves and loads agent state to allow continuing long-running tasks.
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
|
||||
--- Get the resume context directory
|
||||
---@return string|nil
|
||||
local function get_resume_dir()
|
||||
local root = utils.get_project_root() or vim.fn.getcwd()
|
||||
return root .. "/.coder/tmp"
|
||||
end
|
||||
|
||||
--- Get the resume context file path
|
||||
---@return string|nil
|
||||
local function get_resume_path()
|
||||
local dir = get_resume_dir()
|
||||
if not dir then
|
||||
return nil
|
||||
end
|
||||
return dir .. "/agent_resume.json"
|
||||
end
|
||||
|
||||
--- Ensure the resume directory exists
|
||||
---@return boolean
|
||||
local function ensure_resume_dir()
|
||||
local dir = get_resume_dir()
|
||||
if not dir then
|
||||
return false
|
||||
end
|
||||
return utils.ensure_dir(dir)
|
||||
end
|
||||
|
||||
---@class ResumeContext
|
||||
---@field conversation table[] Message history
|
||||
---@field pending_tool_results table[] Pending results
|
||||
---@field iteration number Current iteration count
|
||||
---@field original_prompt string Original user prompt
|
||||
---@field timestamp number When saved
|
||||
---@field project_root string Project root path
|
||||
|
||||
--- Save the current agent state for resuming later
|
||||
---@param conversation table[] Conversation history
|
||||
---@param pending_results table[] Pending tool results
|
||||
---@param iteration number Current iteration
|
||||
---@param original_prompt string Original prompt
|
||||
---@return boolean Success
|
||||
function M.save(conversation, pending_results, iteration, original_prompt)
|
||||
if not ensure_resume_dir() then
|
||||
return false
|
||||
end
|
||||
|
||||
local path = get_resume_path()
|
||||
if not path then
|
||||
return false
|
||||
end
|
||||
|
||||
local context = {
|
||||
conversation = conversation,
|
||||
pending_tool_results = pending_results,
|
||||
iteration = iteration,
|
||||
original_prompt = original_prompt,
|
||||
timestamp = os.time(),
|
||||
project_root = utils.get_project_root() or vim.fn.getcwd(),
|
||||
}
|
||||
|
||||
local ok, json = pcall(vim.json.encode, context)
|
||||
if not ok then
|
||||
utils.notify("Failed to encode resume context", vim.log.levels.ERROR)
|
||||
return false
|
||||
end
|
||||
|
||||
local success = utils.write_file(path, json)
|
||||
if success then
|
||||
utils.notify("Agent state saved. Use /continue to resume.", vim.log.levels.INFO)
|
||||
end
|
||||
return success
|
||||
end
|
||||
|
||||
--- Load saved agent state
|
||||
---@return ResumeContext|nil
|
||||
function M.load()
|
||||
local path = get_resume_path()
|
||||
if not path then
|
||||
return nil
|
||||
end
|
||||
|
||||
local content = utils.read_file(path)
|
||||
if not content or content == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ok, context = pcall(vim.json.decode, content)
|
||||
if not ok or not context then
|
||||
return nil
|
||||
end
|
||||
|
||||
return context
|
||||
end
|
||||
|
||||
--- Check if there's a saved resume context
|
||||
---@return boolean
|
||||
function M.has_saved_state()
|
||||
local path = get_resume_path()
|
||||
if not path then
|
||||
return false
|
||||
end
|
||||
return vim.fn.filereadable(path) == 1
|
||||
end
|
||||
|
||||
--- Get info about saved state (for display)
|
||||
---@return table|nil
|
||||
function M.get_info()
|
||||
local context = M.load()
|
||||
if not context then
|
||||
return nil
|
||||
end
|
||||
|
||||
local age_seconds = os.time() - (context.timestamp or 0)
|
||||
local age_str
|
||||
if age_seconds < 60 then
|
||||
age_str = age_seconds .. " seconds ago"
|
||||
elseif age_seconds < 3600 then
|
||||
age_str = math.floor(age_seconds / 60) .. " minutes ago"
|
||||
else
|
||||
age_str = math.floor(age_seconds / 3600) .. " hours ago"
|
||||
end
|
||||
|
||||
return {
|
||||
prompt = context.original_prompt,
|
||||
iteration = context.iteration,
|
||||
messages = #context.conversation,
|
||||
saved_at = age_str,
|
||||
project = context.project_root,
|
||||
}
|
||||
end
|
||||
|
||||
--- Clear saved resume context
|
||||
---@return boolean
|
||||
function M.clear()
|
||||
local path = get_resume_path()
|
||||
if not path then
|
||||
return false
|
||||
end
|
||||
|
||||
if vim.fn.filereadable(path) == 1 then
|
||||
os.remove(path)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,756 +0,0 @@
|
||||
---@mod codetyper.agent.scheduler Event scheduler with completion-awareness
|
||||
---@brief [[
|
||||
--- Central orchestrator for the event-driven system.
|
||||
--- Handles dispatch, escalation, and completion-safe injection.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local queue = require("codetyper.core.events.queue")
|
||||
local patch = require("codetyper.core.diff.patch")
|
||||
local worker = require("codetyper.core.scheduler.worker")
|
||||
local confidence_mod = require("codetyper.core.llm.confidence")
|
||||
local context_modal = require("codetyper.adapters.nvim.ui.context_modal")
|
||||
local params = require("codetyper.params.agents.scheduler")
|
||||
|
||||
-- Setup context modal cleanup on exit
|
||||
context_modal.setup()
|
||||
|
||||
--- Scheduler state
|
||||
local state = {
|
||||
running = false,
|
||||
timer = nil,
|
||||
poll_interval = 100, -- ms
|
||||
paused = false,
|
||||
config = params.config,
|
||||
}
|
||||
|
||||
--- Autocommand group for injection timing
|
||||
local augroup = nil
|
||||
|
||||
--- Check if completion popup is visible
|
||||
---@return boolean
|
||||
function M.is_completion_visible()
|
||||
-- Check native popup menu
|
||||
if vim.fn.pumvisible() == 1 then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Check nvim-cmp
|
||||
local ok, cmp = pcall(require, "cmp")
|
||||
if ok and cmp.visible and cmp.visible() then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Check coq_nvim
|
||||
local coq_ok, coq = pcall(require, "coq")
|
||||
if coq_ok and coq and type(coq.visible) == "function" and coq.visible() then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if we're in insert mode
|
||||
---@return boolean
|
||||
function M.is_insert_mode()
|
||||
local mode = vim.fn.mode()
|
||||
return mode == "i" or mode == "ic" or mode == "ix"
|
||||
end
|
||||
|
||||
--- Check if it's safe to inject code
|
||||
---@return boolean
|
||||
---@return string|nil reason if not safe
|
||||
function M.is_safe_to_inject()
|
||||
if M.is_completion_visible() then
|
||||
return false, "completion_visible"
|
||||
end
|
||||
|
||||
if M.is_insert_mode() then
|
||||
return false, "insert_mode"
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Get the provider for escalation
|
||||
---@return string
|
||||
local function get_remote_provider()
|
||||
local ok, codetyper = pcall(require, "codetyper")
|
||||
if ok then
|
||||
local config = codetyper.get_config()
|
||||
if config and config.llm and config.llm.provider then
|
||||
-- If current provider is ollama, use configured remote
|
||||
if config.llm.provider == "ollama" then
|
||||
-- Check which remote provider is configured
|
||||
if config.llm.openai and config.llm.openai.api_key then
|
||||
return "openai"
|
||||
elseif config.llm.gemini and config.llm.gemini.api_key then
|
||||
return "gemini"
|
||||
elseif config.llm.copilot then
|
||||
return "copilot"
|
||||
end
|
||||
end
|
||||
return config.llm.provider
|
||||
end
|
||||
end
|
||||
return state.config.remote_provider
|
||||
end
|
||||
|
||||
--- Get the primary provider (ollama if scout enabled, else configured)
|
||||
---@return string
|
||||
local function get_primary_provider()
|
||||
if state.config.ollama_scout then
|
||||
return "ollama"
|
||||
end
|
||||
|
||||
local ok, codetyper = pcall(require, "codetyper")
|
||||
if ok then
|
||||
local config = codetyper.get_config()
|
||||
if config and config.llm and config.llm.provider then
|
||||
return config.llm.provider
|
||||
end
|
||||
end
|
||||
return "ollama"
|
||||
end
|
||||
|
||||
--- Retry event with additional context
|
||||
---@param original_event table Original prompt event
|
||||
---@param additional_context string Additional context from user
|
||||
local function retry_with_context(original_event, additional_context, attached_files)
|
||||
-- Create new prompt content combining original + additional
|
||||
local combined_prompt = string.format(
|
||||
"%s\n\nAdditional context:\n%s",
|
||||
original_event.prompt_content,
|
||||
additional_context
|
||||
)
|
||||
|
||||
-- Create a new event with the combined prompt
|
||||
local new_event = vim.deepcopy(original_event)
|
||||
new_event.id = nil -- Will be assigned a new ID
|
||||
new_event.prompt_content = combined_prompt
|
||||
new_event.attempt_count = 0
|
||||
new_event.status = nil
|
||||
-- Preserve any attached files provided by the context modal
|
||||
if attached_files and #attached_files > 0 then
|
||||
new_event.attached_files = attached_files
|
||||
end
|
||||
|
||||
-- Log the retry
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format("Retrying with additional context (original: %s)", original_event.id),
|
||||
})
|
||||
end)
|
||||
|
||||
-- Queue the new event
|
||||
queue.enqueue(new_event)
|
||||
end
|
||||
|
||||
--- Try to parse requested file paths from an LLM response asking for more context
|
||||
---@param response string
|
||||
---@return string[] list of resolved full paths
|
||||
local function parse_requested_files(response)
|
||||
if not response or response == "" then
|
||||
return {}
|
||||
end
|
||||
|
||||
local cwd = vim.fn.getcwd()
|
||||
local results = {}
|
||||
local seen = {}
|
||||
|
||||
-- Heuristics: capture backticked paths, lines starting with - or *, or raw paths with slashes and extension
|
||||
for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do
|
||||
if not seen[path] then
|
||||
table.insert(results, path)
|
||||
seen[path] = true
|
||||
end
|
||||
end
|
||||
|
||||
for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do
|
||||
if not seen[path] then
|
||||
-- Filter out common English words that match the pattern
|
||||
if not path:match("^[Ii]$") and not path:match("^[Tt]his$") then
|
||||
table.insert(results, path)
|
||||
seen[path] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Also capture list items like '- src/foo.lua'
|
||||
for line in response:gmatch("[^\\n]+") do
|
||||
local m = line:match("^%s*[-*]%s*([%w%._%-%/]+%.[%w_]+)%s*$")
|
||||
if m and not seen[m] then
|
||||
table.insert(results, m)
|
||||
seen[m] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Resolve each candidate to a full path by checking cwd and globbing
|
||||
local resolved = {}
|
||||
for _, p in ipairs(results) do
|
||||
local candidate = p
|
||||
local full = nil
|
||||
|
||||
-- If absolute or already rooted
|
||||
if candidate:sub(1,1) == "/" and vim.fn.filereadable(candidate) == 1 then
|
||||
full = candidate
|
||||
else
|
||||
-- Try relative to cwd
|
||||
local try1 = cwd .. "/" .. candidate
|
||||
if vim.fn.filereadable(try1) == 1 then
|
||||
full = try1
|
||||
else
|
||||
-- Try globbing for filename anywhere in project
|
||||
local basename = candidate
|
||||
-- If candidate contains slashes, try the tail
|
||||
local tail = candidate:match("[^/]+$") or candidate
|
||||
local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true)
|
||||
if matches and #matches > 0 then
|
||||
full = matches[1]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if full and vim.fn.filereadable(full) == 1 then
|
||||
table.insert(resolved, full)
|
||||
end
|
||||
end
|
||||
|
||||
return resolved
|
||||
end
|
||||
|
||||
--- Process worker result and decide next action
|
||||
---@param event table PromptEvent
|
||||
---@param result table WorkerResult
|
||||
local function handle_worker_result(event, result)
|
||||
-- Check if LLM needs more context
|
||||
if result.needs_context then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format("Event %s: LLM needs more context, opening modal", event.id),
|
||||
})
|
||||
end)
|
||||
|
||||
-- Try to auto-attach any files the LLM specifically requested in its response
|
||||
local requested = parse_requested_files(result.response or "")
|
||||
|
||||
-- Detect suggested shell commands the LLM may want executed (e.g., "run ls -la", "please run git status")
|
||||
local function detect_suggested_commands(response)
|
||||
if not response then
|
||||
return {}
|
||||
end
|
||||
local cmds = {}
|
||||
-- capture backticked commands: `ls -la`
|
||||
for c in response:gmatch("`([^`]+)`") do
|
||||
if #c > 1 and not c:match("%-%-help") then
|
||||
table.insert(cmds, { label = c, cmd = c })
|
||||
end
|
||||
end
|
||||
-- capture phrases like: run ls -la or run `ls -la`
|
||||
for m in response:gmatch("[Rr]un%s+([%w%p%s%-_/]+)") do
|
||||
local cand = m:gsub("^%s+",""):gsub("%s+$","")
|
||||
if cand and #cand > 1 then
|
||||
-- ignore long sentences; keep first line or command-like substring
|
||||
local line = cand:match("[^\n]+") or cand
|
||||
line = line:gsub("and then.*","")
|
||||
line = line:gsub("please.*","")
|
||||
if not line:match("%a+%s+files") then
|
||||
table.insert(cmds, { label = line, cmd = line })
|
||||
end
|
||||
end
|
||||
end
|
||||
-- dedupe
|
||||
local seen = {}
|
||||
local out = {}
|
||||
for _, v in ipairs(cmds) do
|
||||
if v.cmd and not seen[v.cmd] then
|
||||
seen[v.cmd] = true
|
||||
table.insert(out, v)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local suggested_cmds = detect_suggested_commands(result.response or "")
|
||||
if suggested_cmds and #suggested_cmds > 0 then
|
||||
-- Open modal and show suggested commands for user approval
|
||||
context_modal.open(result.original_event or event, result.response or "", retry_with_context, suggested_cmds)
|
||||
queue.update_status(event.id, "needs_context", { response = result.response })
|
||||
return
|
||||
end
|
||||
if requested and #requested > 0 then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({ type = "info", message = string.format("Auto-attaching %d requested file(s)", #requested) })
|
||||
end)
|
||||
|
||||
-- Build attached_files entries
|
||||
local attached = event.attached_files or {}
|
||||
for _, full in ipairs(requested) do
|
||||
local ok, content = pcall(function()
|
||||
return table.concat(vim.fn.readfile(full), "\n")
|
||||
end)
|
||||
if ok and content then
|
||||
table.insert(attached, {
|
||||
path = vim.fn.fnamemodify(full, ":~:."),
|
||||
full_path = full,
|
||||
content = content,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Retry automatically with same prompt but attached files
|
||||
local new_event = vim.deepcopy(result.original_event or event)
|
||||
new_event.id = nil
|
||||
new_event.attached_files = attached
|
||||
new_event.attempt_count = 0
|
||||
new_event.status = nil
|
||||
queue.enqueue(new_event)
|
||||
|
||||
queue.update_status(event.id, "needs_context", { response = result.response })
|
||||
return
|
||||
end
|
||||
|
||||
-- If no files parsed, open modal for manual context entry
|
||||
context_modal.open(result.original_event or event, result.response or "", retry_with_context)
|
||||
|
||||
-- Mark original event as needing context (not failed)
|
||||
queue.update_status(event.id, "needs_context", { response = result.response })
|
||||
return
|
||||
end
|
||||
|
||||
if not result.success then
|
||||
-- Failed - try escalation if this was ollama
|
||||
if result.worker_type == "ollama" and event.attempt_count < 2 then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format(
|
||||
"Escalating event %s to remote provider (ollama failed)",
|
||||
event.id
|
||||
),
|
||||
})
|
||||
end)
|
||||
|
||||
event.attempt_count = event.attempt_count + 1
|
||||
event.status = "pending"
|
||||
event.worker_type = get_remote_provider()
|
||||
return
|
||||
end
|
||||
|
||||
-- Mark as failed
|
||||
queue.update_status(event.id, "failed", { error = result.error })
|
||||
return
|
||||
end
|
||||
|
||||
-- Success - check confidence
|
||||
local needs_escalation = confidence_mod.needs_escalation(
|
||||
result.confidence,
|
||||
state.config.escalation_threshold
|
||||
)
|
||||
|
||||
if needs_escalation and result.worker_type == "ollama" and event.attempt_count < 2 then
|
||||
-- Low confidence from ollama - escalate to remote
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format(
|
||||
"Escalating event %s to remote provider (confidence: %.2f < %.2f)",
|
||||
event.id, result.confidence, state.config.escalation_threshold
|
||||
),
|
||||
})
|
||||
end)
|
||||
|
||||
event.attempt_count = event.attempt_count + 1
|
||||
event.status = "pending"
|
||||
event.worker_type = get_remote_provider()
|
||||
return
|
||||
end
|
||||
|
||||
-- Good enough or final attempt - create patch
|
||||
local p = patch.create_from_event(event, result.response, result.confidence)
|
||||
patch.queue_patch(p)
|
||||
|
||||
queue.complete(event.id)
|
||||
|
||||
-- Schedule patch application after delay (gives user time to review/cancel)
|
||||
local delay = state.config.apply_delay_ms or 5000
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format("Code ready. Applying in %.1f seconds...", delay / 1000),
|
||||
})
|
||||
end)
|
||||
|
||||
vim.defer_fn(function()
|
||||
M.schedule_patch_flush()
|
||||
end, delay)
|
||||
end
|
||||
|
||||
--- Dispatch next event from queue
|
||||
local function dispatch_next()
|
||||
if state.paused then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check concurrent limit
|
||||
if worker.active_count() >= state.config.max_concurrent then
|
||||
return
|
||||
end
|
||||
|
||||
-- Get next pending event
|
||||
local event = queue.dequeue()
|
||||
if not event then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check for precedence conflicts (multiple tags in same scope)
|
||||
local should_skip, skip_reason = queue.check_precedence(event)
|
||||
if should_skip then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "warning",
|
||||
message = string.format("Event %s skipped: %s", event.id, skip_reason or "conflict"),
|
||||
})
|
||||
end)
|
||||
queue.cancel(event.id)
|
||||
-- Try next event
|
||||
return dispatch_next()
|
||||
end
|
||||
|
||||
-- Determine which provider to use
|
||||
local provider = event.worker_type or get_primary_provider()
|
||||
|
||||
-- Log dispatch with intent/scope info
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
local intent_info = event.intent and event.intent.type or "unknown"
|
||||
local scope_info = event.scope and event.scope.type ~= "file"
|
||||
and string.format("%s:%s", event.scope.type, event.scope.name or "anon")
|
||||
or "file"
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format(
|
||||
"Dispatching %s [intent: %s, scope: %s, provider: %s]",
|
||||
event.id, intent_info, scope_info, provider
|
||||
),
|
||||
})
|
||||
end)
|
||||
|
||||
-- Create worker
|
||||
worker.create(event, provider, function(result)
|
||||
vim.schedule(function()
|
||||
handle_worker_result(event, result)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Track if we're already waiting to flush (avoid spam logs)
|
||||
local waiting_to_flush = false
|
||||
|
||||
--- Schedule patch flush after delay (completion safety)
|
||||
--- Will keep retrying until safe to inject or no pending patches
|
||||
function M.schedule_patch_flush()
|
||||
vim.defer_fn(function()
|
||||
-- Check if there are any pending patches
|
||||
local pending = patch.get_pending()
|
||||
if #pending == 0 then
|
||||
waiting_to_flush = false
|
||||
return -- Nothing to apply
|
||||
end
|
||||
|
||||
local safe, reason = M.is_safe_to_inject()
|
||||
if safe then
|
||||
waiting_to_flush = false
|
||||
local applied, stale = patch.flush_pending_smart()
|
||||
if applied > 0 or stale > 0 then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format("Patches flushed: %d applied, %d stale", applied, stale),
|
||||
})
|
||||
end)
|
||||
end
|
||||
else
|
||||
-- Not safe yet (user is typing), reschedule to try again
|
||||
-- Only log once when we start waiting
|
||||
if not waiting_to_flush then
|
||||
waiting_to_flush = true
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = "Waiting for user to finish typing before applying code...",
|
||||
})
|
||||
end)
|
||||
end
|
||||
-- Retry after a delay - keep waiting for user to finish typing
|
||||
M.schedule_patch_flush()
|
||||
end
|
||||
end, state.config.completion_delay_ms)
|
||||
end
|
||||
|
||||
--- Main scheduler loop
|
||||
local function scheduler_loop()
|
||||
if not state.running then
|
||||
return
|
||||
end
|
||||
|
||||
dispatch_next()
|
||||
|
||||
-- Cleanup old items periodically
|
||||
if math.random() < 0.01 then -- ~1% chance each tick
|
||||
queue.cleanup(300)
|
||||
patch.cleanup(300)
|
||||
end
|
||||
|
||||
-- Schedule next tick
|
||||
state.timer = vim.defer_fn(scheduler_loop, state.poll_interval)
|
||||
end
|
||||
|
||||
--- Setup autocommands for injection timing
|
||||
local function setup_autocmds()
|
||||
if augroup then
|
||||
pcall(vim.api.nvim_del_augroup_by_id, augroup)
|
||||
end
|
||||
|
||||
augroup = vim.api.nvim_create_augroup("CodetypeScheduler", { clear = true })
|
||||
|
||||
-- Flush patches when leaving insert mode
|
||||
vim.api.nvim_create_autocmd("InsertLeave", {
|
||||
group = augroup,
|
||||
callback = function()
|
||||
vim.defer_fn(function()
|
||||
if not M.is_completion_visible() then
|
||||
patch.flush_pending_smart()
|
||||
end
|
||||
end, state.config.completion_delay_ms)
|
||||
end,
|
||||
desc = "Flush pending patches on InsertLeave",
|
||||
})
|
||||
|
||||
-- Flush patches on cursor hold
|
||||
vim.api.nvim_create_autocmd("CursorHold", {
|
||||
group = augroup,
|
||||
callback = function()
|
||||
if not M.is_insert_mode() and not M.is_completion_visible() then
|
||||
patch.flush_pending_smart()
|
||||
end
|
||||
end,
|
||||
desc = "Flush pending patches on CursorHold",
|
||||
})
|
||||
|
||||
-- Cancel patches when buffer changes significantly
|
||||
vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
group = augroup,
|
||||
callback = function(ev)
|
||||
-- Mark relevant patches as potentially stale
|
||||
-- They'll be checked on next flush attempt
|
||||
end,
|
||||
desc = "Check patch staleness on save",
|
||||
})
|
||||
|
||||
-- Cleanup when buffer is deleted
|
||||
vim.api.nvim_create_autocmd("BufDelete", {
|
||||
group = augroup,
|
||||
callback = function(ev)
|
||||
queue.cancel_for_buffer(ev.buf)
|
||||
patch.cancel_for_buffer(ev.buf)
|
||||
worker.cancel_for_event(ev.buf)
|
||||
end,
|
||||
desc = "Cleanup on buffer delete",
|
||||
})
|
||||
|
||||
-- Stop scheduler when exiting Neovim
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = augroup,
|
||||
callback = function()
|
||||
M.stop()
|
||||
end,
|
||||
desc = "Stop scheduler before exiting Neovim",
|
||||
})
|
||||
end
|
||||
|
||||
--- Start the scheduler
|
||||
---@param config table|nil Configuration overrides
|
||||
function M.start(config)
|
||||
if state.running then
|
||||
return
|
||||
end
|
||||
|
||||
-- Merge config
|
||||
if config then
|
||||
for k, v in pairs(config) do
|
||||
state.config[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
-- Load config from codetyper if available
|
||||
pcall(function()
|
||||
local codetyper = require("codetyper")
|
||||
local ct_config = codetyper.get_config()
|
||||
if ct_config and ct_config.scheduler then
|
||||
for k, v in pairs(ct_config.scheduler) do
|
||||
state.config[k] = v
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if not state.config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
state.running = true
|
||||
state.paused = false
|
||||
|
||||
-- Setup autocmds
|
||||
setup_autocmds()
|
||||
|
||||
-- Add queue listener
|
||||
queue.add_listener(function(event_type, event, queue_size)
|
||||
if event_type == "enqueue" and not state.paused then
|
||||
-- New event - try to dispatch immediately
|
||||
vim.schedule(dispatch_next)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Start main loop
|
||||
scheduler_loop()
|
||||
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = "Scheduler started",
|
||||
data = {
|
||||
ollama_scout = state.config.ollama_scout,
|
||||
escalation_threshold = state.config.escalation_threshold,
|
||||
max_concurrent = state.config.max_concurrent,
|
||||
},
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
--- Stop the scheduler
|
||||
function M.stop()
|
||||
state.running = false
|
||||
|
||||
if state.timer then
|
||||
pcall(function()
|
||||
if type(state.timer) == "userdata" and state.timer.stop then
|
||||
state.timer:stop()
|
||||
end
|
||||
end)
|
||||
state.timer = nil
|
||||
end
|
||||
|
||||
if augroup then
|
||||
pcall(vim.api.nvim_del_augroup_by_id, augroup)
|
||||
augroup = nil
|
||||
end
|
||||
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = "Scheduler stopped",
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
--- Pause the scheduler (don't process new events)
|
||||
function M.pause()
|
||||
state.paused = true
|
||||
end
|
||||
|
||||
--- Resume the scheduler
|
||||
function M.resume()
|
||||
state.paused = false
|
||||
vim.schedule(dispatch_next)
|
||||
end
|
||||
|
||||
--- Check if scheduler is running
|
||||
---@return boolean
|
||||
function M.is_running()
|
||||
return state.running
|
||||
end
|
||||
|
||||
--- Check if scheduler is paused
|
||||
---@return boolean
|
||||
function M.is_paused()
|
||||
return state.paused
|
||||
end
|
||||
|
||||
--- Get scheduler status
|
||||
---@return table
|
||||
function M.status()
|
||||
return {
|
||||
running = state.running,
|
||||
paused = state.paused,
|
||||
queue_stats = queue.stats(),
|
||||
patch_stats = patch.stats(),
|
||||
active_workers = worker.active_count(),
|
||||
config = vim.deepcopy(state.config),
|
||||
}
|
||||
end
|
||||
|
||||
--- Manually trigger dispatch
|
||||
function M.dispatch()
|
||||
if state.running and not state.paused then
|
||||
dispatch_next()
|
||||
end
|
||||
end
|
||||
|
||||
--- Force flush all pending patches (ignores completion check)
|
||||
function M.force_flush()
|
||||
return patch.flush_pending_smart()
|
||||
end
|
||||
|
||||
--- Update configuration
|
||||
---@param config table
|
||||
function M.configure(config)
|
||||
for k, v in pairs(config) do
|
||||
state.config[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
--- Queue a prompt for processing
|
||||
--- This is a convenience function that creates a proper PromptEvent and enqueues it
|
||||
---@param opts table Prompt options
|
||||
--- - bufnr: number Source buffer number
|
||||
--- - filepath: string Source file path
|
||||
--- - target_path: string Target file for injection (can be same as filepath)
|
||||
--- - prompt_content: string The cleaned prompt text
|
||||
--- - range: {start_line: number, end_line: number} Line range of prompt tag
|
||||
--- - source: string|nil Source identifier (e.g., "transform_command", "autocmd")
|
||||
--- - priority: number|nil Priority (1=high, 2=normal, 3=low) default 2
|
||||
---@return table The enqueued event
|
||||
function M.queue_prompt(opts)
|
||||
-- Build the PromptEvent structure
|
||||
local event = {
|
||||
bufnr = opts.bufnr,
|
||||
filepath = opts.filepath,
|
||||
target_path = opts.target_path or opts.filepath,
|
||||
prompt_content = opts.prompt_content,
|
||||
range = opts.range,
|
||||
priority = opts.priority or 2,
|
||||
source = opts.source or "manual",
|
||||
-- Capture buffer state for staleness detection
|
||||
changedtick = vim.api.nvim_buf_get_changedtick(opts.bufnr),
|
||||
}
|
||||
|
||||
-- Enqueue through the queue module
|
||||
return queue.enqueue(event)
|
||||
end
|
||||
|
||||
return M
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,431 +0,0 @@
|
||||
---@mod codetyper.agent.scope Tree-sitter scope resolution
|
||||
---@brief [[
|
||||
--- Resolves semantic scope for prompts using Tree-sitter.
|
||||
--- Finds the smallest enclosing function/method/block for a given position.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ScopeInfo
|
||||
---@field type string "function"|"method"|"class"|"block"|"file"|"unknown"
|
||||
---@field node_type string Tree-sitter node type
|
||||
---@field range {start_row: number, start_col: number, end_row: number, end_col: number}
|
||||
---@field text string The full text of the scope
|
||||
---@field name string|nil Name of the function/class if available
|
||||
|
||||
--- Node types that represent function-like scopes per language
|
||||
local params = require("codetyper.params.agents.scope")
|
||||
local function_nodes = params.function_nodes
|
||||
local class_nodes = params.class_nodes
|
||||
local block_nodes = params.block_nodes
|
||||
|
||||
--- Check if Tree-sitter is available for buffer
|
||||
---@param bufnr number
|
||||
---@return boolean
|
||||
function M.has_treesitter(bufnr)
|
||||
-- Try to get the language for this buffer
|
||||
local lang = nil
|
||||
|
||||
-- Method 1: Use vim.treesitter (Neovim 0.9+)
|
||||
if vim.treesitter and vim.treesitter.language then
|
||||
local ft = vim.bo[bufnr].filetype
|
||||
if vim.treesitter.language.get_lang then
|
||||
lang = vim.treesitter.language.get_lang(ft)
|
||||
else
|
||||
lang = ft
|
||||
end
|
||||
end
|
||||
|
||||
-- Method 2: Try nvim-treesitter parsers module
|
||||
if not lang then
|
||||
local ok, parsers = pcall(require, "nvim-treesitter.parsers")
|
||||
if ok and parsers then
|
||||
if parsers.get_buf_lang then
|
||||
lang = parsers.get_buf_lang(bufnr)
|
||||
elseif parsers.ft_to_lang then
|
||||
lang = parsers.ft_to_lang(vim.bo[bufnr].filetype)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Fallback to filetype
|
||||
if not lang then
|
||||
lang = vim.bo[bufnr].filetype
|
||||
end
|
||||
|
||||
if not lang or lang == "" then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if parser is available
|
||||
local has_parser = pcall(vim.treesitter.get_parser, bufnr, lang)
|
||||
return has_parser
|
||||
end
|
||||
|
||||
--- Get Tree-sitter node at position
|
||||
---@param bufnr number
|
||||
---@param row number 0-indexed
|
||||
---@param col number 0-indexed
|
||||
---@return TSNode|nil
|
||||
local function get_node_at_pos(bufnr, row, col)
|
||||
local ok, ts_utils = pcall(require, "nvim-treesitter.ts_utils")
|
||||
if not ok then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Try to get the node at the cursor position
|
||||
local node = ts_utils.get_node_at_cursor()
|
||||
if node then
|
||||
return node
|
||||
end
|
||||
|
||||
-- Fallback: get root and find node
|
||||
local parser = vim.treesitter.get_parser(bufnr)
|
||||
if not parser then
|
||||
return nil
|
||||
end
|
||||
|
||||
local tree = parser:parse()[1]
|
||||
if not tree then
|
||||
return nil
|
||||
end
|
||||
|
||||
local root = tree:root()
|
||||
return root:named_descendant_for_range(row, col, row, col)
|
||||
end
|
||||
|
||||
--- Find enclosing scope node of specific types
|
||||
---@param node TSNode
|
||||
---@param node_types table<string, string>
|
||||
---@return TSNode|nil, string|nil scope_type
|
||||
local function find_enclosing_scope(node, node_types)
|
||||
local current = node
|
||||
while current do
|
||||
local node_type = current:type()
|
||||
if node_types[node_type] then
|
||||
return current, node_types[node_type]
|
||||
end
|
||||
current = current:parent()
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Extract function/method name from node
|
||||
---@param node TSNode
|
||||
---@param bufnr number
|
||||
---@return string|nil
|
||||
local function get_scope_name(node, bufnr)
|
||||
-- Try to find name child node
|
||||
local name_node = node:field("name")[1]
|
||||
if name_node then
|
||||
return vim.treesitter.get_node_text(name_node, bufnr)
|
||||
end
|
||||
|
||||
-- Try identifier child
|
||||
for child in node:iter_children() do
|
||||
if child:type() == "identifier" or child:type() == "property_identifier" then
|
||||
return vim.treesitter.get_node_text(child, bufnr)
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Resolve scope at position using Tree-sitter
|
||||
---@param bufnr number Buffer number
|
||||
---@param row number 1-indexed line number
|
||||
---@param col number 1-indexed column number
|
||||
---@return ScopeInfo
|
||||
function M.resolve_scope(bufnr, row, col)
|
||||
-- Default to file scope
|
||||
local default_scope = {
|
||||
type = "file",
|
||||
node_type = "file",
|
||||
range = {
|
||||
start_row = 1,
|
||||
start_col = 0,
|
||||
end_row = vim.api.nvim_buf_line_count(bufnr),
|
||||
end_col = 0,
|
||||
},
|
||||
text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n"),
|
||||
name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":t"),
|
||||
}
|
||||
|
||||
-- Check if Tree-sitter is available
|
||||
if not M.has_treesitter(bufnr) then
|
||||
-- Fall back to heuristic-based scope resolution
|
||||
return M.resolve_scope_heuristic(bufnr, row, col) or default_scope
|
||||
end
|
||||
|
||||
-- Convert to 0-indexed for Tree-sitter
|
||||
local ts_row = row - 1
|
||||
local ts_col = col - 1
|
||||
|
||||
-- Get node at position
|
||||
local node = get_node_at_pos(bufnr, ts_row, ts_col)
|
||||
if not node then
|
||||
return default_scope
|
||||
end
|
||||
|
||||
-- Try to find function scope first
|
||||
local scope_node, scope_type = find_enclosing_scope(node, function_nodes)
|
||||
|
||||
-- If no function, try class
|
||||
if not scope_node then
|
||||
scope_node, scope_type = find_enclosing_scope(node, class_nodes)
|
||||
end
|
||||
|
||||
-- If no class, try block
|
||||
if not scope_node then
|
||||
scope_node, scope_type = find_enclosing_scope(node, block_nodes)
|
||||
end
|
||||
|
||||
if not scope_node then
|
||||
return default_scope
|
||||
end
|
||||
|
||||
-- Get range (convert back to 1-indexed)
|
||||
local start_row, start_col, end_row, end_col = scope_node:range()
|
||||
|
||||
-- Get text
|
||||
local text = vim.treesitter.get_node_text(scope_node, bufnr)
|
||||
|
||||
-- Get name
|
||||
local name = get_scope_name(scope_node, bufnr)
|
||||
|
||||
return {
|
||||
type = scope_type,
|
||||
node_type = scope_node:type(),
|
||||
range = {
|
||||
start_row = start_row + 1,
|
||||
start_col = start_col,
|
||||
end_row = end_row + 1,
|
||||
end_col = end_col,
|
||||
},
|
||||
text = text,
|
||||
name = name,
|
||||
}
|
||||
end
|
||||
|
||||
--- Heuristic fallback for scope resolution (no Tree-sitter)
|
||||
---@param bufnr number
|
||||
---@param row number 1-indexed
|
||||
---@param col number 1-indexed
|
||||
---@return ScopeInfo|nil
|
||||
function M.resolve_scope_heuristic(bufnr, row, col)
|
||||
_ = col -- unused in heuristic
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local filetype = vim.bo[bufnr].filetype
|
||||
|
||||
-- Language-specific function patterns
|
||||
local patterns = {
|
||||
lua = {
|
||||
start = "^%s*local%s+function%s+",
|
||||
start_alt = "^%s*function%s+",
|
||||
ending = "^%s*end%s*$",
|
||||
},
|
||||
python = {
|
||||
start = "^%s*def%s+",
|
||||
start_alt = "^%s*async%s+def%s+",
|
||||
ending = nil, -- Python uses indentation
|
||||
},
|
||||
javascript = {
|
||||
start = "^%s*export%s+function%s+",
|
||||
start_alt = "^%s*function%s+",
|
||||
start_alt2 = "^%s*export%s+const%s+%w+%s*=",
|
||||
start_alt3 = "^%s*const%s+%w+%s*=%s*",
|
||||
start_alt4 = "^%s*export%s+async%s+function%s+",
|
||||
start_alt5 = "^%s*async%s+function%s+",
|
||||
ending = "^%s*}%s*$",
|
||||
},
|
||||
typescript = {
|
||||
start = "^%s*export%s+function%s+",
|
||||
start_alt = "^%s*function%s+",
|
||||
start_alt2 = "^%s*export%s+const%s+%w+%s*=",
|
||||
start_alt3 = "^%s*const%s+%w+%s*=%s*",
|
||||
start_alt4 = "^%s*export%s+async%s+function%s+",
|
||||
start_alt5 = "^%s*async%s+function%s+",
|
||||
ending = "^%s*}%s*$",
|
||||
},
|
||||
}
|
||||
|
||||
local lang_patterns = patterns[filetype]
|
||||
if not lang_patterns then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Find function start (search backwards)
|
||||
local start_line = nil
|
||||
for i = row, 1, -1 do
|
||||
local line = lines[i]
|
||||
-- Check all start patterns
|
||||
if line:match(lang_patterns.start)
|
||||
or (lang_patterns.start_alt and line:match(lang_patterns.start_alt))
|
||||
or (lang_patterns.start_alt2 and line:match(lang_patterns.start_alt2))
|
||||
or (lang_patterns.start_alt3 and line:match(lang_patterns.start_alt3))
|
||||
or (lang_patterns.start_alt4 and line:match(lang_patterns.start_alt4))
|
||||
or (lang_patterns.start_alt5 and line:match(lang_patterns.start_alt5)) then
|
||||
start_line = i
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not start_line then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Find function end
|
||||
local end_line = nil
|
||||
if lang_patterns.ending then
|
||||
-- Brace/end based languages
|
||||
local depth = 0
|
||||
for i = start_line, #lines do
|
||||
local line = lines[i]
|
||||
-- Count braces or end keywords
|
||||
if filetype == "lua" then
|
||||
if line:match("function") or line:match("if") or line:match("for") or line:match("while") then
|
||||
depth = depth + 1
|
||||
end
|
||||
if line:match("^%s*end") then
|
||||
depth = depth - 1
|
||||
if depth <= 0 then
|
||||
end_line = i
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
-- JavaScript/TypeScript brace counting
|
||||
for _ in line:gmatch("{") do depth = depth + 1 end
|
||||
for _ in line:gmatch("}") do depth = depth - 1 end
|
||||
if depth <= 0 and i > start_line then
|
||||
end_line = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Python: use indentation
|
||||
local base_indent = #(lines[start_line]:match("^%s*") or "")
|
||||
for i = start_line + 1, #lines do
|
||||
local line = lines[i]
|
||||
if line:match("^%s*$") then
|
||||
goto continue
|
||||
end
|
||||
local indent = #(line:match("^%s*") or "")
|
||||
if indent <= base_indent then
|
||||
end_line = i - 1
|
||||
break
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
end_line = end_line or #lines
|
||||
end
|
||||
|
||||
if not end_line then
|
||||
end_line = #lines
|
||||
end
|
||||
|
||||
-- Extract text
|
||||
local scope_lines = {}
|
||||
for i = start_line, end_line do
|
||||
table.insert(scope_lines, lines[i])
|
||||
end
|
||||
|
||||
-- Try to extract function name
|
||||
local name = nil
|
||||
local first_line = lines[start_line]
|
||||
name = first_line:match("function%s+([%w_]+)") or
|
||||
first_line:match("def%s+([%w_]+)") or
|
||||
first_line:match("const%s+([%w_]+)")
|
||||
|
||||
return {
|
||||
type = "function",
|
||||
node_type = "heuristic",
|
||||
range = {
|
||||
start_row = start_line,
|
||||
start_col = 0,
|
||||
end_row = end_line,
|
||||
end_col = #lines[end_line],
|
||||
},
|
||||
text = table.concat(scope_lines, "\n"),
|
||||
name = name,
|
||||
}
|
||||
end
|
||||
|
||||
--- Get scope for the current cursor position
|
||||
---@return ScopeInfo
|
||||
function M.resolve_scope_at_cursor()
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local cursor = vim.api.nvim_win_get_cursor(0)
|
||||
return M.resolve_scope(bufnr, cursor[1], cursor[2] + 1)
|
||||
end
|
||||
|
||||
--- Check if position is inside a function/method
|
||||
---@param bufnr number
|
||||
---@param row number 1-indexed
|
||||
---@param col number 1-indexed
|
||||
---@return boolean
|
||||
function M.is_in_function(bufnr, row, col)
|
||||
local scope = M.resolve_scope(bufnr, row, col)
|
||||
return scope.type == "function" or scope.type == "method"
|
||||
end
|
||||
|
||||
--- Get all functions in buffer
|
||||
---@param bufnr number
|
||||
---@return ScopeInfo[]
|
||||
function M.get_all_functions(bufnr)
|
||||
local functions = {}
|
||||
|
||||
if not M.has_treesitter(bufnr) then
|
||||
return functions
|
||||
end
|
||||
|
||||
local parser = vim.treesitter.get_parser(bufnr)
|
||||
if not parser then
|
||||
return functions
|
||||
end
|
||||
|
||||
local tree = parser:parse()[1]
|
||||
if not tree then
|
||||
return functions
|
||||
end
|
||||
|
||||
local root = tree:root()
|
||||
|
||||
-- Query for all function nodes
|
||||
local lang = parser:lang()
|
||||
local query_string = [[
|
||||
(function_declaration) @func
|
||||
(function_definition) @func
|
||||
(method_definition) @func
|
||||
(arrow_function) @func
|
||||
]]
|
||||
|
||||
local ok, query = pcall(vim.treesitter.query.parse, lang, query_string)
|
||||
if not ok then
|
||||
return functions
|
||||
end
|
||||
|
||||
for _, node in query:iter_captures(root, bufnr, 0, -1) do
|
||||
local start_row, start_col, end_row, end_col = node:range()
|
||||
local text = vim.treesitter.get_node_text(node, bufnr)
|
||||
local name = get_scope_name(node, bufnr)
|
||||
|
||||
table.insert(functions, {
|
||||
type = function_nodes[node:type()] or "function",
|
||||
node_type = node:type(),
|
||||
range = {
|
||||
start_row = start_row + 1,
|
||||
start_col = start_col,
|
||||
end_row = end_row + 1,
|
||||
end_col = end_col,
|
||||
},
|
||||
text = text,
|
||||
name = name,
|
||||
})
|
||||
end
|
||||
|
||||
return functions
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,128 +0,0 @@
|
||||
---@mod codetyper.agent.tools.base Base tool definition
|
||||
---@brief [[
|
||||
--- Base metatable for all LLM tools.
|
||||
--- Tools extend this base to provide structured AI capabilities.
|
||||
---@brief ]]
|
||||
|
||||
---@class CoderToolParam
|
||||
---@field name string Parameter name
|
||||
---@field description string Parameter description
|
||||
---@field type string Parameter type ("string", "number", "boolean", "table")
|
||||
---@field optional? boolean Whether the parameter is optional
|
||||
---@field default? any Default value for optional parameters
|
||||
|
||||
---@class CoderToolReturn
|
||||
---@field name string Return value name
|
||||
---@field description string Return value description
|
||||
---@field type string Return type
|
||||
---@field optional? boolean Whether the return is optional
|
||||
|
||||
---@class CoderToolOpts
|
||||
---@field on_log? fun(message: string) Log callback
|
||||
---@field on_complete? fun(result: any, error: string|nil) Completion callback
|
||||
---@field session_ctx? table Session context
|
||||
---@field streaming? boolean Whether response is still streaming
|
||||
---@field confirm? fun(message: string, callback: fun(ok: boolean)) Confirmation callback
|
||||
|
||||
---@class CoderTool
|
||||
---@field name string Tool identifier
|
||||
---@field description string|fun(): string Tool description
|
||||
---@field params CoderToolParam[] Input parameters
|
||||
---@field returns CoderToolReturn[] Return values
|
||||
---@field requires_confirmation? boolean Whether tool needs user confirmation
|
||||
---@field func fun(input: table, opts: CoderToolOpts): any, string|nil Tool implementation
|
||||
|
||||
local M = {}
|
||||
M.__index = M
|
||||
|
||||
--- Call the tool function
|
||||
---@param opts CoderToolOpts Options for the tool call
|
||||
---@return any result
|
||||
---@return string|nil error
|
||||
function M:__call(opts, on_log, on_complete)
|
||||
return self.func(opts, on_log, on_complete)
|
||||
end
|
||||
|
||||
--- Get the tool description
|
||||
---@return string
|
||||
function M:get_description()
|
||||
if type(self.description) == "function" then
|
||||
return self.description()
|
||||
end
|
||||
return self.description
|
||||
end
|
||||
|
||||
--- Validate input against parameter schema
|
||||
---@param input table Input to validate
|
||||
---@return boolean valid
|
||||
---@return string|nil error
|
||||
function M:validate_input(input)
|
||||
if not self.params then
|
||||
return true
|
||||
end
|
||||
|
||||
for _, param in ipairs(self.params) do
|
||||
local value = input[param.name]
|
||||
|
||||
-- Check required parameters
|
||||
if not param.optional and value == nil then
|
||||
return false, string.format("Missing required parameter: %s", param.name)
|
||||
end
|
||||
|
||||
-- Type checking
|
||||
if value ~= nil then
|
||||
local actual_type = type(value)
|
||||
local expected_type = param.type
|
||||
|
||||
-- Handle special types
|
||||
if expected_type == "integer" and actual_type == "number" then
|
||||
if math.floor(value) ~= value then
|
||||
return false, string.format("Parameter %s must be an integer", param.name)
|
||||
end
|
||||
elseif expected_type ~= actual_type and expected_type ~= "any" then
|
||||
return false, string.format("Parameter %s must be %s, got %s", param.name, expected_type, actual_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Generate JSON schema for the tool (for LLM function calling)
|
||||
---@return table schema
|
||||
function M:to_schema()
|
||||
local properties = {}
|
||||
local required = {}
|
||||
|
||||
for _, param in ipairs(self.params or {}) do
|
||||
local prop = {
|
||||
type = param.type == "integer" and "number" or param.type,
|
||||
description = param.description,
|
||||
}
|
||||
|
||||
if param.default ~= nil then
|
||||
prop.default = param.default
|
||||
end
|
||||
|
||||
properties[param.name] = prop
|
||||
|
||||
if not param.optional then
|
||||
table.insert(required, param.name)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
type = "function",
|
||||
function_def = {
|
||||
name = self.name,
|
||||
description = self:get_description(),
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,139 +0,0 @@
|
||||
---@mod codetyper.agent.tools.bash Shell command execution tool
|
||||
---@brief [[
|
||||
--- Tool for executing shell commands with safety checks.
|
||||
---@brief ]]
|
||||
|
||||
local Base = require("codetyper.core.tools.base")
|
||||
local description = require("codetyper.prompts.agents.bash").description
|
||||
local params = require("codetyper.params.agents.bash").params
|
||||
local returns = require("codetyper.params.agents.bash").returns
|
||||
local BANNED_COMMANDS = require("codetyper.commands.agents.banned").BANNED_COMMANDS
|
||||
local BANNED_PATTERNS = require("codetyper.commands.agents.banned").BANNED_PATTERNS
|
||||
|
||||
---@class CoderTool
|
||||
local M = setmetatable({}, Base)
|
||||
|
||||
M.name = "bash"
|
||||
M.description = description
|
||||
M.params = params
|
||||
M.returns = returns
|
||||
M.requires_confirmation = true
|
||||
|
||||
--- Check if command is safe
|
||||
---@param command string
|
||||
---@return boolean safe
|
||||
---@return string|nil reason
|
||||
local function is_safe_command(command)
|
||||
-- Check exact matches
|
||||
for _, banned in ipairs(BANNED_COMMANDS) do
|
||||
if command == banned then
|
||||
return false, "Command is banned for safety"
|
||||
end
|
||||
end
|
||||
|
||||
-- Check patterns
|
||||
for _, pattern in ipairs(BANNED_PATTERNS) do
|
||||
if command:match(pattern) then
|
||||
return false, "Command matches banned pattern"
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
---@param input {command: string, cwd?: string, timeout?: integer}
|
||||
---@param opts CoderToolOpts
|
||||
---@return string|nil result
|
||||
---@return string|nil error
|
||||
function M.func(input, opts)
|
||||
if not input.command then
|
||||
return nil, "command is required"
|
||||
end
|
||||
|
||||
-- Safety check
|
||||
local safe, reason = is_safe_command(input.command)
|
||||
if not safe then
|
||||
return nil, reason
|
||||
end
|
||||
|
||||
-- Confirmation required
|
||||
if M.requires_confirmation and opts.confirm then
|
||||
local confirmed = false
|
||||
local confirm_error = nil
|
||||
|
||||
opts.confirm("Execute command: " .. input.command, function(ok)
|
||||
if not ok then
|
||||
confirm_error = "User declined command execution"
|
||||
end
|
||||
confirmed = ok
|
||||
end)
|
||||
|
||||
-- Wait for confirmation (in async context, this would be handled differently)
|
||||
if confirm_error then
|
||||
return nil, confirm_error
|
||||
end
|
||||
end
|
||||
|
||||
-- Log the operation
|
||||
if opts.on_log then
|
||||
opts.on_log("Executing: " .. input.command)
|
||||
end
|
||||
|
||||
-- Prepare command
|
||||
local cwd = input.cwd or vim.fn.getcwd()
|
||||
local timeout = input.timeout or 120000
|
||||
|
||||
-- Execute command
|
||||
local output = ""
|
||||
local exit_code = 0
|
||||
|
||||
local job_opts = {
|
||||
command = "bash",
|
||||
args = { "-c", input.command },
|
||||
cwd = cwd,
|
||||
on_stdout = function(_, data)
|
||||
if data then
|
||||
output = output .. table.concat(data, "\n")
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data then
|
||||
output = output .. table.concat(data, "\n")
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
exit_code = code
|
||||
end,
|
||||
}
|
||||
|
||||
-- Run synchronously with timeout
|
||||
local Job = require("plenary.job")
|
||||
local job = Job:new(job_opts)
|
||||
|
||||
job:sync(timeout)
|
||||
exit_code = job.code or 0
|
||||
output = table.concat(job:result() or {}, "\n")
|
||||
|
||||
-- Also get stderr
|
||||
local stderr = table.concat(job:stderr_result() or {}, "\n")
|
||||
if stderr and stderr ~= "" then
|
||||
output = output .. "\n" .. stderr
|
||||
end
|
||||
|
||||
-- Check result
|
||||
if exit_code ~= 0 then
|
||||
local error_msg = string.format("Command failed with exit code %d: %s", exit_code, output)
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, error_msg)
|
||||
end
|
||||
return nil, error_msg
|
||||
end
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(output, nil)
|
||||
end
|
||||
|
||||
return output, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,391 +0,0 @@
|
||||
---@mod codetyper.agent.tools.edit File editing tool with fallback matching
|
||||
---@brief [[
|
||||
--- Tool for making targeted edits to files using search/replace.
|
||||
--- Implements multiple fallback strategies for robust matching.
|
||||
--- Multi-strategy approach for reliable editing.
|
||||
---@brief ]]
|
||||
|
||||
local Base = require("codetyper.core.tools.base")
|
||||
local description = require("codetyper.prompts.agents.edit").description
|
||||
local params = require("codetyper.params.agents.edit").params
|
||||
local returns = require("codetyper.params.agents.edit").returns
|
||||
|
||||
---@class CoderTool
|
||||
local M = setmetatable({}, Base)
|
||||
|
||||
M.name = "edit"
|
||||
M.description = description
|
||||
M.params = params
|
||||
M.returns = returns
|
||||
M.requires_confirmation = false
|
||||
|
||||
--- Normalize line endings to LF
|
||||
---@param str string
|
||||
---@return string
|
||||
local function normalize_line_endings(str)
|
||||
return str:gsub("\r\n", "\n"):gsub("\r", "\n")
|
||||
end
|
||||
|
||||
--- Strategy 1: Exact match
|
||||
---@param content string File content
|
||||
---@param old_str string String to find
|
||||
---@return number|nil start_pos
|
||||
---@return number|nil end_pos
|
||||
local function exact_match(content, old_str)
|
||||
local pos = content:find(old_str, 1, true)
|
||||
if pos then
|
||||
return pos, pos + #old_str - 1
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Strategy 2: Whitespace-normalized match
|
||||
--- Collapses all whitespace to single spaces
|
||||
---@param content string
|
||||
---@param old_str string
|
||||
---@return number|nil start_pos
|
||||
---@return number|nil end_pos
|
||||
local function whitespace_normalized_match(content, old_str)
|
||||
local function normalize_ws(s)
|
||||
return s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
end
|
||||
|
||||
local norm_old = normalize_ws(old_str)
|
||||
local lines = vim.split(content, "\n")
|
||||
|
||||
-- Try to find matching block
|
||||
for i = 1, #lines do
|
||||
local block = {}
|
||||
local block_start = nil
|
||||
|
||||
for j = i, #lines do
|
||||
table.insert(block, lines[j])
|
||||
local block_text = table.concat(block, "\n")
|
||||
local norm_block = normalize_ws(block_text)
|
||||
|
||||
if norm_block == norm_old then
|
||||
-- Found match
|
||||
local before = table.concat(vim.list_slice(lines, 1, i - 1), "\n")
|
||||
local start_pos = #before + (i > 1 and 2 or 1)
|
||||
local end_pos = start_pos + #block_text - 1
|
||||
return start_pos, end_pos
|
||||
end
|
||||
|
||||
-- If block is already longer than target, stop
|
||||
if #norm_block > #norm_old then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Strategy 3: Indentation-flexible match
|
||||
--- Ignores leading whitespace differences
|
||||
---@param content string
|
||||
---@param old_str string
|
||||
---@return number|nil start_pos
|
||||
---@return number|nil end_pos
|
||||
local function indentation_flexible_match(content, old_str)
|
||||
local function strip_indent(s)
|
||||
local lines = vim.split(s, "\n")
|
||||
local result = {}
|
||||
for _, line in ipairs(lines) do
|
||||
table.insert(result, line:gsub("^%s+", ""))
|
||||
end
|
||||
return table.concat(result, "\n")
|
||||
end
|
||||
|
||||
local stripped_old = strip_indent(old_str)
|
||||
local lines = vim.split(content, "\n")
|
||||
local old_lines = vim.split(old_str, "\n")
|
||||
local num_old_lines = #old_lines
|
||||
|
||||
for i = 1, #lines - num_old_lines + 1 do
|
||||
local block = vim.list_slice(lines, i, i + num_old_lines - 1)
|
||||
local block_text = table.concat(block, "\n")
|
||||
|
||||
if strip_indent(block_text) == stripped_old then
|
||||
local before = table.concat(vim.list_slice(lines, 1, i - 1), "\n")
|
||||
local start_pos = #before + (i > 1 and 2 or 1)
|
||||
local end_pos = start_pos + #block_text - 1
|
||||
return start_pos, end_pos
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Strategy 4: Line-trimmed match
|
||||
--- Trims each line before comparing
|
||||
---@param content string
|
||||
---@param old_str string
|
||||
---@return number|nil start_pos
|
||||
---@return number|nil end_pos
|
||||
local function line_trimmed_match(content, old_str)
|
||||
local function trim_lines(s)
|
||||
local lines = vim.split(s, "\n")
|
||||
local result = {}
|
||||
for _, line in ipairs(lines) do
|
||||
table.insert(result, line:match("^%s*(.-)%s*$"))
|
||||
end
|
||||
return table.concat(result, "\n")
|
||||
end
|
||||
|
||||
local trimmed_old = trim_lines(old_str)
|
||||
local lines = vim.split(content, "\n")
|
||||
local old_lines = vim.split(old_str, "\n")
|
||||
local num_old_lines = #old_lines
|
||||
|
||||
for i = 1, #lines - num_old_lines + 1 do
|
||||
local block = vim.list_slice(lines, i, i + num_old_lines - 1)
|
||||
local block_text = table.concat(block, "\n")
|
||||
|
||||
if trim_lines(block_text) == trimmed_old then
|
||||
local before = table.concat(vim.list_slice(lines, 1, i - 1), "\n")
|
||||
local start_pos = #before + (i > 1 and 2 or 1)
|
||||
local end_pos = start_pos + #block_text - 1
|
||||
return start_pos, end_pos
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Calculate Levenshtein distance between two strings
|
||||
---@param s1 string
|
||||
---@param s2 string
|
||||
---@return number
|
||||
local function levenshtein(s1, s2)
|
||||
local len1, len2 = #s1, #s2
|
||||
local matrix = {}
|
||||
|
||||
for i = 0, len1 do
|
||||
matrix[i] = { [0] = i }
|
||||
end
|
||||
for j = 0, len2 do
|
||||
matrix[0][j] = j
|
||||
end
|
||||
|
||||
for i = 1, len1 do
|
||||
for j = 1, len2 do
|
||||
local cost = s1:sub(i, i) == s2:sub(j, j) and 0 or 1
|
||||
matrix[i][j] = math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
end
|
||||
end
|
||||
|
||||
return matrix[len1][len2]
|
||||
end
|
||||
|
||||
--- Strategy 5: Fuzzy anchor-based match
|
||||
--- Uses first and last lines as anchors, allows fuzzy matching in between
|
||||
---@param content string
|
||||
---@param old_str string
|
||||
---@param threshold? number Similarity threshold (0-1), default 0.8
|
||||
---@return number|nil start_pos
|
||||
---@return number|nil end_pos
|
||||
local function fuzzy_anchor_match(content, old_str, threshold)
|
||||
threshold = threshold or 0.8
|
||||
|
||||
local old_lines = vim.split(old_str, "\n")
|
||||
if #old_lines < 2 then
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local first_line = old_lines[1]:match("^%s*(.-)%s*$")
|
||||
local last_line = old_lines[#old_lines]:match("^%s*(.-)%s*$")
|
||||
local content_lines = vim.split(content, "\n")
|
||||
|
||||
-- Find potential start positions
|
||||
local candidates = {}
|
||||
for i, line in ipairs(content_lines) do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if
|
||||
trimmed == first_line
|
||||
or (
|
||||
#first_line > 0
|
||||
and 1 - (levenshtein(trimmed, first_line) / math.max(#trimmed, #first_line)) >= threshold
|
||||
)
|
||||
then
|
||||
table.insert(candidates, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- For each candidate, look for matching end
|
||||
for _, start_idx in ipairs(candidates) do
|
||||
local expected_end = start_idx + #old_lines - 1
|
||||
if expected_end <= #content_lines then
|
||||
local end_line = content_lines[expected_end]:match("^%s*(.-)%s*$")
|
||||
if
|
||||
end_line == last_line
|
||||
or (
|
||||
#last_line > 0
|
||||
and 1 - (levenshtein(end_line, last_line) / math.max(#end_line, #last_line)) >= threshold
|
||||
)
|
||||
then
|
||||
-- Calculate positions
|
||||
local before = table.concat(vim.list_slice(content_lines, 1, start_idx - 1), "\n")
|
||||
local block = table.concat(vim.list_slice(content_lines, start_idx, expected_end), "\n")
|
||||
local start_pos = #before + (start_idx > 1 and 2 or 1)
|
||||
local end_pos = start_pos + #block - 1
|
||||
return start_pos, end_pos
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Try all matching strategies in order
|
||||
---@param content string File content
|
||||
---@param old_str string String to find
|
||||
---@return number|nil start_pos
|
||||
---@return number|nil end_pos
|
||||
---@return string strategy_used
|
||||
local function find_match(content, old_str)
|
||||
-- Strategy 1: Exact match
|
||||
local start_pos, end_pos = exact_match(content, old_str)
|
||||
if start_pos then
|
||||
return start_pos, end_pos, "exact"
|
||||
end
|
||||
|
||||
-- Strategy 2: Whitespace-normalized
|
||||
start_pos, end_pos = whitespace_normalized_match(content, old_str)
|
||||
if start_pos then
|
||||
return start_pos, end_pos, "whitespace_normalized"
|
||||
end
|
||||
|
||||
-- Strategy 3: Indentation-flexible
|
||||
start_pos, end_pos = indentation_flexible_match(content, old_str)
|
||||
if start_pos then
|
||||
return start_pos, end_pos, "indentation_flexible"
|
||||
end
|
||||
|
||||
-- Strategy 4: Line-trimmed
|
||||
start_pos, end_pos = line_trimmed_match(content, old_str)
|
||||
if start_pos then
|
||||
return start_pos, end_pos, "line_trimmed"
|
||||
end
|
||||
|
||||
-- Strategy 5: Fuzzy anchor
|
||||
start_pos, end_pos = fuzzy_anchor_match(content, old_str)
|
||||
if start_pos then
|
||||
return start_pos, end_pos, "fuzzy_anchor"
|
||||
end
|
||||
|
||||
return nil, nil, "none"
|
||||
end
|
||||
|
||||
---@param input {path: string, old_string: string, new_string: string}
|
||||
---@param opts CoderToolOpts
|
||||
---@return boolean|nil result
|
||||
---@return string|nil error
|
||||
function M.func(input, opts)
|
||||
if not input.path then
|
||||
return nil, "path is required"
|
||||
end
|
||||
if input.old_string == nil then
|
||||
return nil, "old_string is required"
|
||||
end
|
||||
if input.new_string == nil then
|
||||
return nil, "new_string is required"
|
||||
end
|
||||
|
||||
-- Log the operation
|
||||
if opts.on_log then
|
||||
opts.on_log("Editing file: " .. input.path)
|
||||
end
|
||||
|
||||
-- Resolve path
|
||||
local path = input.path
|
||||
if not vim.startswith(path, "/") then
|
||||
path = vim.fn.getcwd() .. "/" .. path
|
||||
end
|
||||
|
||||
-- Normalize inputs
|
||||
local old_str = normalize_line_endings(input.old_string)
|
||||
local new_str = normalize_line_endings(input.new_string)
|
||||
|
||||
-- Handle new file creation (empty old_string)
|
||||
if old_str == "" then
|
||||
-- Create parent directories
|
||||
local dir = vim.fn.fnamemodify(path, ":h")
|
||||
if vim.fn.isdirectory(dir) == 0 then
|
||||
vim.fn.mkdir(dir, "p")
|
||||
end
|
||||
|
||||
-- Write new file
|
||||
local lines = vim.split(new_str, "\n", { plain = true })
|
||||
local ok = pcall(vim.fn.writefile, lines, path)
|
||||
|
||||
if not ok then
|
||||
return nil, "Failed to create file: " .. input.path
|
||||
end
|
||||
|
||||
-- Reload buffer if open
|
||||
local bufnr = vim.fn.bufnr(path)
|
||||
if bufnr ~= -1 and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
vim.api.nvim_buf_call(bufnr, function()
|
||||
vim.cmd("edit!")
|
||||
end)
|
||||
end
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(true, nil)
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
-- Check if file exists
|
||||
if vim.fn.filereadable(path) ~= 1 then
|
||||
return nil, "File not found: " .. input.path
|
||||
end
|
||||
|
||||
-- Read current content
|
||||
local lines = vim.fn.readfile(path)
|
||||
if not lines then
|
||||
return nil, "Failed to read file: " .. input.path
|
||||
end
|
||||
|
||||
local content = normalize_line_endings(table.concat(lines, "\n"))
|
||||
|
||||
-- Find match using fallback strategies
|
||||
local start_pos, end_pos, strategy = find_match(content, old_str)
|
||||
|
||||
if not start_pos then
|
||||
return nil, "old_string not found in file (tried 5 matching strategies)"
|
||||
end
|
||||
|
||||
if opts.on_log then
|
||||
opts.on_log("Match found using strategy: " .. strategy)
|
||||
end
|
||||
|
||||
-- Perform replacement
|
||||
local new_content = content:sub(1, start_pos - 1) .. new_str .. content:sub(end_pos + 1)
|
||||
|
||||
-- Write back
|
||||
local new_lines = vim.split(new_content, "\n", { plain = true })
|
||||
local ok = pcall(vim.fn.writefile, new_lines, path)
|
||||
|
||||
if not ok then
|
||||
return nil, "Failed to write file: " .. input.path
|
||||
end
|
||||
|
||||
-- Reload buffer if open
|
||||
local bufnr = vim.fn.bufnr(path)
|
||||
if bufnr ~= -1 and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
vim.api.nvim_buf_call(bufnr, function()
|
||||
vim.cmd("edit!")
|
||||
end)
|
||||
end
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(true, nil)
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,146 +0,0 @@
|
||||
---@mod codetyper.agent.tools.glob File pattern matching tool
|
||||
---@brief [[
|
||||
--- Tool for finding files by glob pattern.
|
||||
---@brief ]]
|
||||
|
||||
local Base = require("codetyper.core.tools.base")
|
||||
|
||||
---@class CoderTool
|
||||
local M = setmetatable({}, Base)
|
||||
|
||||
M.name = "glob"
|
||||
|
||||
M.description = [[Finds files matching a glob pattern.
|
||||
|
||||
Example patterns:
|
||||
- "**/*.lua" - All Lua files
|
||||
- "src/**/*.ts" - TypeScript files in src
|
||||
- "**/test_*.py" - Test files in Python]]
|
||||
|
||||
M.params = {
|
||||
{
|
||||
name = "pattern",
|
||||
description = "Glob pattern to match files",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "path",
|
||||
description = "Base directory to search in (default: project root)",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
{
|
||||
name = "max_results",
|
||||
description = "Maximum number of results (default: 100)",
|
||||
type = "integer",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
M.returns = {
|
||||
{
|
||||
name = "matches",
|
||||
description = "JSON array of matching file paths",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "error",
|
||||
description = "Error message if glob failed",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
M.requires_confirmation = false
|
||||
|
||||
---@param input {pattern: string, path?: string, max_results?: integer}
|
||||
---@param opts CoderToolOpts
|
||||
---@return string|nil result
|
||||
---@return string|nil error
|
||||
function M.func(input, opts)
|
||||
if not input.pattern then
|
||||
return nil, "pattern is required"
|
||||
end
|
||||
|
||||
-- Log the operation
|
||||
if opts.on_log then
|
||||
opts.on_log("Finding files: " .. input.pattern)
|
||||
end
|
||||
|
||||
-- Resolve base path
|
||||
local base_path = input.path or vim.fn.getcwd()
|
||||
if not vim.startswith(base_path, "/") then
|
||||
base_path = vim.fn.getcwd() .. "/" .. base_path
|
||||
end
|
||||
|
||||
local max_results = input.max_results or 100
|
||||
|
||||
-- Use vim.fn.glob or fd if available
|
||||
local matches = {}
|
||||
|
||||
if vim.fn.executable("fd") == 1 then
|
||||
-- Use fd for better performance
|
||||
local Job = require("plenary.job")
|
||||
|
||||
-- Convert glob to fd pattern
|
||||
local fd_pattern = input.pattern:gsub("%*%*/", ""):gsub("%*", ".*")
|
||||
|
||||
local job = Job:new({
|
||||
command = "fd",
|
||||
args = {
|
||||
"--type",
|
||||
"f",
|
||||
"--max-results",
|
||||
tostring(max_results),
|
||||
"--glob",
|
||||
input.pattern,
|
||||
base_path,
|
||||
},
|
||||
cwd = base_path,
|
||||
})
|
||||
|
||||
job:sync(30000)
|
||||
matches = job:result() or {}
|
||||
else
|
||||
-- Fallback to vim.fn.globpath
|
||||
local pattern = base_path .. "/" .. input.pattern
|
||||
local files = vim.fn.glob(pattern, false, true)
|
||||
|
||||
for i, file in ipairs(files) do
|
||||
if i > max_results then
|
||||
break
|
||||
end
|
||||
-- Make paths relative to base_path
|
||||
local relative = file:gsub("^" .. vim.pesc(base_path) .. "/", "")
|
||||
table.insert(matches, relative)
|
||||
end
|
||||
end
|
||||
|
||||
-- Clean up matches
|
||||
local cleaned = {}
|
||||
for _, match in ipairs(matches) do
|
||||
if match and match ~= "" then
|
||||
-- Make relative if absolute
|
||||
local relative = match
|
||||
if vim.startswith(match, base_path) then
|
||||
relative = match:sub(#base_path + 2)
|
||||
end
|
||||
table.insert(cleaned, relative)
|
||||
end
|
||||
end
|
||||
|
||||
-- Return as JSON
|
||||
local result = vim.json.encode({
|
||||
matches = cleaned,
|
||||
total = #cleaned,
|
||||
truncated = #cleaned >= max_results,
|
||||
})
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(result, nil)
|
||||
end
|
||||
|
||||
return result, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,107 +0,0 @@
|
||||
---@mod codetyper.agent.tools.grep Search tool
|
||||
---@brief [[
|
||||
--- Tool for searching file contents using ripgrep.
|
||||
---@brief ]]
|
||||
|
||||
local Base = require("codetyper.core.tools.base")
|
||||
local description = require("codetyper.params.agents.grep").description
|
||||
local params = require("codetyper.prompts.agents.grep").params
|
||||
local returns = require("codetyper.prompts.agents.grep").returns
|
||||
|
||||
---@class CoderTool
|
||||
local M = setmetatable({}, Base)
|
||||
|
||||
M.name = "grep"
|
||||
M.description = description
|
||||
M.params = params
|
||||
M.returns = returns
|
||||
|
||||
M.requires_confirmation = false
|
||||
|
||||
---@param input {pattern: string, path?: string, include?: string, max_results?: integer}
|
||||
---@param opts CoderToolOpts
|
||||
---@return string|nil result
|
||||
---@return string|nil error
|
||||
function M.func(input, opts)
|
||||
if not input.pattern then
|
||||
return nil, "pattern is required"
|
||||
end
|
||||
|
||||
-- Log the operation
|
||||
if opts.on_log then
|
||||
opts.on_log("Searching for: " .. input.pattern)
|
||||
end
|
||||
|
||||
-- Build ripgrep command
|
||||
local path = input.path or vim.fn.getcwd()
|
||||
local max_results = input.max_results or 50
|
||||
|
||||
-- Resolve path
|
||||
if not vim.startswith(path, "/") then
|
||||
path = vim.fn.getcwd() .. "/" .. path
|
||||
end
|
||||
|
||||
-- Check if ripgrep is available
|
||||
if vim.fn.executable("rg") ~= 1 then
|
||||
return nil, "ripgrep (rg) is not installed"
|
||||
end
|
||||
|
||||
-- Build command args
|
||||
local args = {
|
||||
"--json",
|
||||
"--max-count",
|
||||
tostring(max_results),
|
||||
"--no-heading",
|
||||
}
|
||||
|
||||
if input.include then
|
||||
table.insert(args, "--glob")
|
||||
table.insert(args, input.include)
|
||||
end
|
||||
|
||||
table.insert(args, input.pattern)
|
||||
table.insert(args, path)
|
||||
|
||||
-- Execute ripgrep
|
||||
local Job = require("plenary.job")
|
||||
local job = Job:new({
|
||||
command = "rg",
|
||||
args = args,
|
||||
cwd = vim.fn.getcwd(),
|
||||
})
|
||||
|
||||
job:sync(30000) -- 30 second timeout
|
||||
|
||||
local results = job:result() or {}
|
||||
local matches = {}
|
||||
|
||||
-- Parse JSON output
|
||||
for _, line in ipairs(results) do
|
||||
if line and line ~= "" then
|
||||
local ok, parsed = pcall(vim.json.decode, line)
|
||||
if ok and parsed.type == "match" then
|
||||
local data = parsed.data
|
||||
table.insert(matches, {
|
||||
file = data.path.text,
|
||||
line_number = data.line_number,
|
||||
content = data.lines.text:gsub("\n$", ""),
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Return as JSON
|
||||
local result = vim.json.encode({
|
||||
matches = matches,
|
||||
total = #matches,
|
||||
truncated = #matches >= max_results,
|
||||
})
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(result, nil)
|
||||
end
|
||||
|
||||
return result, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,90 +0,0 @@
|
||||
---@mod codetyper.agent.tools Tool definitions for the agent system
|
||||
---
|
||||
--- Defines available tools that the LLM can use to interact with files and system.
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Tool definitions in a provider-agnostic format
|
||||
M.definitions = require("codetyper.params.agents.tools").definitions
|
||||
|
||||
--- Convert tool definitions to Claude API format
|
||||
---@return table[] Tools in Claude's expected format
|
||||
function M.to_claude_format()
|
||||
local tools = {}
|
||||
for _, tool in pairs(M.definitions) do
|
||||
table.insert(tools, {
|
||||
name = tool.name,
|
||||
description = tool.description,
|
||||
input_schema = tool.parameters,
|
||||
})
|
||||
end
|
||||
return tools
|
||||
end
|
||||
|
||||
--- Convert tool definitions to OpenAI API format
|
||||
---@return table[] Tools in OpenAI's expected format
|
||||
function M.to_openai_format()
|
||||
local tools = {}
|
||||
for _, tool in pairs(M.definitions) do
|
||||
table.insert(tools, {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = tool.name,
|
||||
description = tool.description,
|
||||
parameters = tool.parameters,
|
||||
},
|
||||
})
|
||||
end
|
||||
return tools
|
||||
end
|
||||
|
||||
--- Convert tool definitions to prompt format for Ollama
|
||||
---@return string Formatted tool descriptions for system prompt
|
||||
function M.to_prompt_format()
|
||||
local prompts = require("codetyper.prompts.agents.tools").instructions
|
||||
local lines = {
|
||||
prompts.intro,
|
||||
"",
|
||||
}
|
||||
|
||||
for _, tool in pairs(M.definitions) do
|
||||
table.insert(lines, "## " .. tool.name)
|
||||
table.insert(lines, tool.description)
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, "Parameters:")
|
||||
for prop_name, prop in pairs(tool.parameters.properties) do
|
||||
local required = vim.tbl_contains(tool.parameters.required or {}, prop_name)
|
||||
local req_str = required and " (required)" or " (optional)"
|
||||
table.insert(lines, " - " .. prop_name .. ": " .. prop.description .. req_str)
|
||||
end
|
||||
table.insert(lines, "")
|
||||
end
|
||||
|
||||
table.insert(lines, "---")
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, prompts.header)
|
||||
table.insert(lines, prompts.example)
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, prompts.footer)
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
--- Get a list of tool names
|
||||
---@return string[]
|
||||
function M.get_tool_names()
|
||||
local names = {}
|
||||
for name, _ in pairs(M.definitions) do
|
||||
table.insert(names, name)
|
||||
end
|
||||
return names
|
||||
end
|
||||
|
||||
--- Optional setup function for future extensibility
|
||||
---@param opts table|nil Configuration options
|
||||
function M.setup(opts)
|
||||
-- Currently a no-op. Plugins or tests may call setup(); keep for compatibility.
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
---@mod codetyper.agent.tools Tool registry and orchestration
|
||||
---@brief [[
|
||||
--- Registry for LLM tools with execution and schema generation.
|
||||
--- Tool system for agent mode.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Registered tools
|
||||
---@type table<string, CoderTool>
|
||||
local tools = {}
|
||||
|
||||
--- Tool execution history for current session
|
||||
---@type table[]
|
||||
local execution_history = {}
|
||||
|
||||
--- Register a tool
|
||||
---@param tool CoderTool Tool to register
|
||||
function M.register(tool)
|
||||
if not tool.name then
|
||||
error("Tool must have a name")
|
||||
end
|
||||
tools[tool.name] = tool
|
||||
end
|
||||
|
||||
--- Unregister a tool
|
||||
---@param name string Tool name
|
||||
function M.unregister(name)
|
||||
tools[name] = nil
|
||||
end
|
||||
|
||||
--- Get a tool by name
|
||||
---@param name string Tool name
|
||||
---@return CoderTool|nil
|
||||
function M.get(name)
|
||||
return tools[name]
|
||||
end
|
||||
|
||||
--- Get all registered tools
|
||||
---@return table<string, CoderTool>
|
||||
function M.get_all()
|
||||
return tools
|
||||
end
|
||||
|
||||
--- Get tools as a list
|
||||
---@param filter? fun(tool: CoderTool): boolean Optional filter function
|
||||
---@return CoderTool[]
|
||||
function M.list(filter)
|
||||
local result = {}
|
||||
for _, tool in pairs(tools) do
|
||||
if not filter or filter(tool) then
|
||||
table.insert(result, tool)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
--- Generate schemas for all tools (for LLM function calling)
|
||||
---@param filter? fun(tool: CoderTool): boolean Optional filter function
|
||||
---@return table[] schemas
|
||||
function M.get_schemas(filter)
|
||||
local schemas = {}
|
||||
for _, tool in pairs(tools) do
|
||||
if not filter or filter(tool) then
|
||||
if tool.to_schema then
|
||||
table.insert(schemas, tool:to_schema())
|
||||
end
|
||||
end
|
||||
end
|
||||
return schemas
|
||||
end
|
||||
|
||||
--- Execute a tool by name
|
||||
---@param name string Tool name
|
||||
---@param input table Input parameters
|
||||
---@param opts CoderToolOpts Execution options
|
||||
---@return any result
|
||||
---@return string|nil error
|
||||
function M.execute(name, input, opts)
|
||||
local tool = tools[name]
|
||||
if not tool then
|
||||
return nil, "Unknown tool: " .. name
|
||||
end
|
||||
|
||||
-- Validate input
|
||||
if tool.validate_input then
|
||||
local valid, err = tool:validate_input(input)
|
||||
if not valid then
|
||||
return nil, err
|
||||
end
|
||||
end
|
||||
|
||||
-- Log execution
|
||||
if opts.on_log then
|
||||
opts.on_log(string.format("Executing tool: %s", name))
|
||||
end
|
||||
|
||||
-- Track execution
|
||||
local execution = {
|
||||
tool = name,
|
||||
input = input,
|
||||
start_time = os.time(),
|
||||
status = "running",
|
||||
}
|
||||
table.insert(execution_history, execution)
|
||||
|
||||
-- Execute the tool
|
||||
local result, err = tool.func(input, opts)
|
||||
|
||||
-- Update execution record
|
||||
execution.end_time = os.time()
|
||||
execution.status = err and "error" or "completed"
|
||||
execution.result = result
|
||||
execution.error = err
|
||||
|
||||
return result, err
|
||||
end
|
||||
|
||||
--- Process a tool call from LLM response
|
||||
---@param tool_call table Tool call from LLM (name + input)
|
||||
---@param opts CoderToolOpts Execution options
|
||||
---@return any result
|
||||
---@return string|nil error
|
||||
function M.process_tool_call(tool_call, opts)
|
||||
local name = tool_call.name or tool_call.function_name
|
||||
local input = tool_call.input or tool_call.arguments or {}
|
||||
|
||||
-- Parse JSON arguments if string
|
||||
if type(input) == "string" then
|
||||
local ok, parsed = pcall(vim.json.decode, input)
|
||||
if ok then
|
||||
input = parsed
|
||||
else
|
||||
return nil, "Failed to parse tool arguments: " .. input
|
||||
end
|
||||
end
|
||||
|
||||
return M.execute(name, input, opts)
|
||||
end
|
||||
|
||||
--- Get execution history
|
||||
---@param limit? number Max entries to return
|
||||
---@return table[]
|
||||
function M.get_history(limit)
|
||||
if not limit then
|
||||
return execution_history
|
||||
end
|
||||
|
||||
local result = {}
|
||||
local start = math.max(1, #execution_history - limit + 1)
|
||||
for i = start, #execution_history do
|
||||
table.insert(result, execution_history[i])
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
--- Clear execution history
|
||||
function M.clear_history()
|
||||
execution_history = {}
|
||||
end
|
||||
|
||||
--- Load built-in tools
|
||||
function M.load_builtins()
|
||||
-- View file tool
|
||||
local view = require("codetyper.core.tools.view")
|
||||
M.register(view)
|
||||
|
||||
-- Bash tool
|
||||
local bash = require("codetyper.core.tools.bash")
|
||||
M.register(bash)
|
||||
|
||||
-- Grep tool
|
||||
local grep = require("codetyper.core.tools.grep")
|
||||
M.register(grep)
|
||||
|
||||
-- Glob tool
|
||||
local glob = require("codetyper.core.tools.glob")
|
||||
M.register(glob)
|
||||
|
||||
-- Write file tool
|
||||
local write = require("codetyper.core.tools.write")
|
||||
M.register(write)
|
||||
|
||||
-- Edit tool
|
||||
local edit = require("codetyper.core.tools.edit")
|
||||
M.register(edit)
|
||||
end
|
||||
|
||||
--- Initialize tools system
|
||||
function M.setup()
|
||||
M.load_builtins()
|
||||
end
|
||||
|
||||
--- Get tool definitions for LLM (lazy-loaded, OpenAI format)
|
||||
--- This is accessed as M.definitions property
|
||||
M.definitions = setmetatable({}, {
|
||||
__call = function()
|
||||
-- Ensure tools are loaded
|
||||
if vim.tbl_count(tools) == 0 then
|
||||
M.load_builtins()
|
||||
end
|
||||
return M.to_openai_format()
|
||||
end,
|
||||
__index = function(_, key)
|
||||
-- Make it work as both function and table
|
||||
if key == "get" then
|
||||
return function()
|
||||
if vim.tbl_count(tools) == 0 then
|
||||
M.load_builtins()
|
||||
end
|
||||
return M.to_openai_format()
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
})
|
||||
|
||||
--- Get definitions as a function (for backwards compatibility)
|
||||
function M.get_definitions()
|
||||
if vim.tbl_count(tools) == 0 then
|
||||
M.load_builtins()
|
||||
end
|
||||
return M.to_openai_format()
|
||||
end
|
||||
|
||||
--- Convert all tools to OpenAI function calling format
|
||||
---@param filter? fun(tool: CoderTool): boolean Optional filter function
|
||||
---@return table[] OpenAI-compatible tool definitions
|
||||
function M.to_openai_format(filter)
|
||||
local openai_tools = {}
|
||||
|
||||
for _, tool in pairs(tools) do
|
||||
if not filter or filter(tool) then
|
||||
local properties = {}
|
||||
local required = {}
|
||||
|
||||
for _, param in ipairs(tool.params or {}) do
|
||||
properties[param.name] = {
|
||||
type = param.type == "integer" and "number" or param.type,
|
||||
description = param.description,
|
||||
}
|
||||
if param.default ~= nil then
|
||||
properties[param.name].default = param.default
|
||||
end
|
||||
if not param.optional then
|
||||
table.insert(required, param.name)
|
||||
end
|
||||
end
|
||||
|
||||
local description = type(tool.description) == "function" and tool.description() or tool.description
|
||||
|
||||
table.insert(openai_tools, {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = tool.name,
|
||||
description = description,
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
},
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return openai_tools
|
||||
end
|
||||
|
||||
--- Convert all tools to Claude tool use format
|
||||
---@param filter? fun(tool: CoderTool): boolean Optional filter function
|
||||
---@return table[] Claude-compatible tool definitions
|
||||
function M.to_claude_format(filter)
|
||||
local claude_tools = {}
|
||||
|
||||
for _, tool in pairs(tools) do
|
||||
if not filter or filter(tool) then
|
||||
local properties = {}
|
||||
local required = {}
|
||||
|
||||
for _, param in ipairs(tool.params or {}) do
|
||||
properties[param.name] = {
|
||||
type = param.type == "integer" and "number" or param.type,
|
||||
description = param.description,
|
||||
}
|
||||
if not param.optional then
|
||||
table.insert(required, param.name)
|
||||
end
|
||||
end
|
||||
|
||||
local description = type(tool.description) == "function" and tool.description() or tool.description
|
||||
|
||||
table.insert(claude_tools, {
|
||||
name = tool.name,
|
||||
description = description,
|
||||
input_schema = {
|
||||
type = "object",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return claude_tools
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,114 +0,0 @@
|
||||
---@mod codetyper.agent.tools.view File viewing tool
|
||||
---@brief [[
|
||||
--- Tool for reading file contents with line range support.
|
||||
---@brief ]]
|
||||
|
||||
local Base = require("codetyper.core.tools.base")
|
||||
|
||||
---@class CoderTool
|
||||
local M = setmetatable({}, Base)
|
||||
|
||||
M.name = "view"
|
||||
|
||||
local params = require("codetyper.params.agents.view")
|
||||
local description = require("codetyper.prompts.agents.view").description
|
||||
|
||||
M.description = description
|
||||
M.params = params.params
|
||||
M.returns = params.returns
|
||||
|
||||
M.requires_confirmation = false
|
||||
|
||||
--- Maximum content size before truncation
|
||||
local MAX_CONTENT_SIZE = 200 * 1024 -- 200KB
|
||||
|
||||
---@param input {path: string, start_line?: integer, end_line?: integer}
|
||||
---@param opts CoderToolOpts
|
||||
---@return string|nil result
|
||||
---@return string|nil error
|
||||
function M.func(input, opts)
|
||||
if not input.path then
|
||||
return nil, "path is required"
|
||||
end
|
||||
|
||||
-- Log the operation
|
||||
if opts.on_log then
|
||||
opts.on_log("Reading file: " .. input.path)
|
||||
end
|
||||
|
||||
-- Resolve path
|
||||
local path = input.path
|
||||
if not vim.startswith(path, "/") then
|
||||
-- Relative path - resolve from project root
|
||||
local root = vim.fn.getcwd()
|
||||
path = root .. "/" .. path
|
||||
end
|
||||
|
||||
-- Check if file exists
|
||||
local stat = vim.uv.fs_stat(path)
|
||||
if not stat then
|
||||
return nil, "File not found: " .. input.path
|
||||
end
|
||||
|
||||
if stat.type == "directory" then
|
||||
return nil, "Path is a directory: " .. input.path
|
||||
end
|
||||
|
||||
-- Read file
|
||||
local lines = vim.fn.readfile(path)
|
||||
if not lines then
|
||||
return nil, "Failed to read file: " .. input.path
|
||||
end
|
||||
|
||||
-- Apply line range
|
||||
local start_line = input.start_line or 1
|
||||
local end_line = input.end_line or #lines
|
||||
|
||||
start_line = math.max(1, start_line)
|
||||
end_line = math.min(#lines, end_line)
|
||||
|
||||
local total_lines = #lines
|
||||
local selected_lines = {}
|
||||
|
||||
for i = start_line, end_line do
|
||||
table.insert(selected_lines, lines[i])
|
||||
end
|
||||
|
||||
-- Check for truncation
|
||||
local content = table.concat(selected_lines, "\n")
|
||||
local is_truncated = false
|
||||
|
||||
if #content > MAX_CONTENT_SIZE then
|
||||
-- Truncate content
|
||||
local truncated_lines = {}
|
||||
local size = 0
|
||||
|
||||
for _, line in ipairs(selected_lines) do
|
||||
size = size + #line + 1
|
||||
if size > MAX_CONTENT_SIZE then
|
||||
is_truncated = true
|
||||
break
|
||||
end
|
||||
table.insert(truncated_lines, line)
|
||||
end
|
||||
|
||||
content = table.concat(truncated_lines, "\n")
|
||||
end
|
||||
|
||||
-- Return as JSON
|
||||
local result = vim.json.encode({
|
||||
content = content,
|
||||
total_line_count = total_lines,
|
||||
is_truncated = is_truncated,
|
||||
start_line = start_line,
|
||||
end_line = end_line,
|
||||
})
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(result, nil)
|
||||
end
|
||||
|
||||
return result, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,72 +0,0 @@
|
||||
---@mod codetyper.agent.tools.write File writing tool
|
||||
---@brief [[
|
||||
--- Tool for creating or overwriting files.
|
||||
---@brief ]]
|
||||
|
||||
local Base = require("codetyper.core.tools.base")
|
||||
local description = require("codetyper.prompts.agents.write").description
|
||||
local params = require("codetyper.params.agents.write")
|
||||
|
||||
---@class CoderTool
|
||||
local M = setmetatable({}, Base)
|
||||
|
||||
M.name = "write"
|
||||
M.description = description
|
||||
M.params = params.params
|
||||
M.returns = params.returns
|
||||
|
||||
M.requires_confirmation = true
|
||||
|
||||
---@param input {path: string, content: string}
|
||||
---@param opts CoderToolOpts
|
||||
---@return boolean|nil result
|
||||
---@return string|nil error
|
||||
function M.func(input, opts)
|
||||
if not input.path then
|
||||
return nil, "path is required"
|
||||
end
|
||||
if not input.content then
|
||||
return nil, "content is required"
|
||||
end
|
||||
|
||||
-- Log the operation
|
||||
if opts.on_log then
|
||||
opts.on_log("Writing file: " .. input.path)
|
||||
end
|
||||
|
||||
-- Resolve path
|
||||
local path = input.path
|
||||
if not vim.startswith(path, "/") then
|
||||
path = vim.fn.getcwd() .. "/" .. path
|
||||
end
|
||||
|
||||
-- Create parent directories
|
||||
local dir = vim.fn.fnamemodify(path, ":h")
|
||||
if vim.fn.isdirectory(dir) == 0 then
|
||||
vim.fn.mkdir(dir, "p")
|
||||
end
|
||||
|
||||
-- Write the file
|
||||
local lines = vim.split(input.content, "\n", { plain = true })
|
||||
local ok = pcall(vim.fn.writefile, lines, path)
|
||||
|
||||
if not ok then
|
||||
return nil, "Failed to write file: " .. path
|
||||
end
|
||||
|
||||
-- Reload buffer if open
|
||||
local bufnr = vim.fn.bufnr(path)
|
||||
if bufnr ~= -1 and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
vim.api.nvim_buf_call(bufnr, function()
|
||||
vim.cmd("edit!")
|
||||
end)
|
||||
end
|
||||
|
||||
if opts.on_complete then
|
||||
opts.on_complete(true, nil)
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,268 +0,0 @@
|
||||
---@mod codetyper.agent.context_builder Context builder for agent prompts
|
||||
---
|
||||
--- Builds rich context including project structure, memories, and conventions
|
||||
--- to help the LLM understand the codebase.
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
local params = require("codetyper.params.agents.context")
|
||||
|
||||
--- Get project structure as a tree string
|
||||
---@param max_depth? number Maximum depth to traverse (default: 3)
|
||||
---@param max_files? number Maximum files to show (default: 50)
|
||||
---@return string Project tree
|
||||
function M.get_project_structure(max_depth, max_files)
|
||||
max_depth = max_depth or 3
|
||||
max_files = max_files or 50
|
||||
|
||||
local root = utils.get_project_root() or vim.fn.getcwd()
|
||||
local lines = { "PROJECT STRUCTURE:", root, "" }
|
||||
local file_count = 0
|
||||
|
||||
-- Common ignore patterns
|
||||
local ignore_patterns = params.ignore_patterns
|
||||
|
||||
local function should_ignore(name)
|
||||
for _, pattern in ipairs(ignore_patterns) do
|
||||
if name:match(pattern) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function traverse(path, depth, prefix)
|
||||
if depth > max_depth or file_count >= max_files then
|
||||
return
|
||||
end
|
||||
|
||||
local entries = {}
|
||||
local handle = vim.loop.fs_scandir(path)
|
||||
if not handle then
|
||||
return
|
||||
end
|
||||
|
||||
while true do
|
||||
local name, type = vim.loop.fs_scandir_next(handle)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
if not should_ignore(name) then
|
||||
table.insert(entries, { name = name, type = type })
|
||||
end
|
||||
end
|
||||
|
||||
-- Sort: directories first, then alphabetically
|
||||
table.sort(entries, function(a, b)
|
||||
if a.type == "directory" and b.type ~= "directory" then
|
||||
return true
|
||||
elseif a.type ~= "directory" and b.type == "directory" then
|
||||
return false
|
||||
else
|
||||
return a.name < b.name
|
||||
end
|
||||
end)
|
||||
|
||||
for i, entry in ipairs(entries) do
|
||||
if file_count >= max_files then
|
||||
table.insert(lines, prefix .. "... (truncated)")
|
||||
return
|
||||
end
|
||||
|
||||
local is_last = (i == #entries)
|
||||
local branch = is_last and "└── " or "├── "
|
||||
local new_prefix = prefix .. (is_last and " " or "│ ")
|
||||
|
||||
local icon = entry.type == "directory" and "/" or ""
|
||||
table.insert(lines, prefix .. branch .. entry.name .. icon)
|
||||
file_count = file_count + 1
|
||||
|
||||
if entry.type == "directory" then
|
||||
traverse(path .. "/" .. entry.name, depth + 1, new_prefix)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
traverse(root, 1, "")
|
||||
|
||||
if file_count >= max_files then
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, "(Structure truncated at " .. max_files .. " entries)")
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
--- Get key files that are important for understanding the project
|
||||
---@return table<string, string> Map of filename to description
|
||||
function M.get_key_files()
|
||||
local root = utils.get_project_root() or vim.fn.getcwd()
|
||||
local key_files = {}
|
||||
|
||||
local important_files = {
|
||||
["package.json"] = "Node.js project config",
|
||||
["Cargo.toml"] = "Rust project config",
|
||||
["go.mod"] = "Go module config",
|
||||
["pyproject.toml"] = "Python project config",
|
||||
["setup.py"] = "Python setup config",
|
||||
["Makefile"] = "Build configuration",
|
||||
["CMakeLists.txt"] = "CMake config",
|
||||
[".gitignore"] = "Git ignore patterns",
|
||||
["README.md"] = "Project documentation",
|
||||
["init.lua"] = "Neovim plugin entry",
|
||||
["plugin.lua"] = "Neovim plugin config",
|
||||
}
|
||||
|
||||
for filename, desc in paparams.important_filesnd
|
||||
|
||||
return key_files
|
||||
end
|
||||
|
||||
--- Detect project type and language
|
||||
---@return table { type: string, language: string, framework?: string }
|
||||
function M.detect_project_type()
|
||||
local root = utils.get_project_root() or vim.fn.getcwd()
|
||||
|
||||
local indicators = {
|
||||
["package.json"] = { type = "node", language = "javascript/typescript" },
|
||||
["Cargo.toml"] = { type = "rust", language = "rust" },
|
||||
["go.mod"] = { type = "go", language = "go" },
|
||||
["pyproject.toml"] = { type = "python", language = "python" },
|
||||
["setup.py"] = { type = "python", language = "python" },
|
||||
["Gemfile"] = { type = "ruby", language = "ruby" },
|
||||
["pom.xml"] = { type = "maven", language = "java" },
|
||||
["build.gradle"] = { type = "gradle", language = "java/kotlin" },
|
||||
}
|
||||
|
||||
-- Check for Neovim plugin specifically
|
||||
if vim.fn.isdirectoparams.indicators return info
|
||||
end
|
||||
end
|
||||
|
||||
return { type = "unknown", language = "unknown" }
|
||||
end
|
||||
|
||||
--- Get memories/patterns from the brain system
|
||||
---@return string Formatted memories context
|
||||
function M.get_memories_context()
|
||||
local ok_memory, memory = pcall(require, "codetyper.indexer.memory")
|
||||
if not ok_memory then
|
||||
return ""
|
||||
end
|
||||
|
||||
local all = memory.get_all()
|
||||
if not all then
|
||||
return ""
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
|
||||
-- Add patterns
|
||||
if all.patterns and next(all.patterns) then
|
||||
table.insert(lines, "LEARNED PATTERNS:")
|
||||
local count = 0
|
||||
for _, mem in pairs(all.patterns) do
|
||||
if count >= 5 then
|
||||
break
|
||||
end
|
||||
if mem.content then
|
||||
table.insert(lines, " - " .. mem.content:sub(1, 100))
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
end
|
||||
|
||||
-- Add conventions
|
||||
if all.conventions and next(all.conventions) then
|
||||
table.insert(lines, "CODING CONVENTIONS:")
|
||||
local count = 0
|
||||
for _, mem in pairs(all.conventions) do
|
||||
if count >= 5 then
|
||||
break
|
||||
end
|
||||
if mem.content then
|
||||
table.insert(lines, " - " .. mem.content:sub(1, 100))
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
--- Build the full context for agent prompts
|
||||
---@return string Full context string
|
||||
function M.build_full_context()
|
||||
local sections = {}
|
||||
|
||||
-- Project info
|
||||
local project_type = M.detect_project_type()
|
||||
table.insert(sections, string.format(
|
||||
"PROJECT INFO:\n Type: %s\n Language: %s%s\n",
|
||||
project_type.type,
|
||||
project_type.language,
|
||||
project_type.framework and ("\n Framework: " .. project_type.framework) or ""
|
||||
))
|
||||
|
||||
-- Project structure
|
||||
local structure = M.get_project_structure(3, 40)
|
||||
table.insert(sections, structure)
|
||||
|
||||
-- Key files
|
||||
local key_files = M.get_key_files()
|
||||
if next(key_files) then
|
||||
local key_lines = { "", "KEY FILES:" }
|
||||
for name, info in pairs(key_files) do
|
||||
table.insert(key_lines, string.format(" %s - %s", name, info.description))
|
||||
end
|
||||
table.insert(sections, table.concat(key_lines, "\n"))
|
||||
end
|
||||
|
||||
-- Memories
|
||||
local memories = M.get_memories_context()
|
||||
if memories ~= "" then
|
||||
table.insert(sections, "\n" .. memories)
|
||||
end
|
||||
|
||||
return table.concat(sections, "\n")
|
||||
end
|
||||
|
||||
--- Get a compact context summary for token efficiency
|
||||
---@return string Compact context
|
||||
function M.build_compact_context()
|
||||
local root = utils.get_project_root() or vim.fn.getcwd()
|
||||
local project_type = M.detect_project_type()
|
||||
|
||||
local lines = {
|
||||
"CONTEXT:",
|
||||
" Root: " .. root,
|
||||
" Type: " .. project_type.type .. " (" .. project_type.language .. ")",
|
||||
}
|
||||
|
||||
-- Add main directories
|
||||
local main_dirs = {}
|
||||
local handle = vim.loop.fs_scandir(root)
|
||||
if handle then
|
||||
while true do
|
||||
local name, type = vim.loop.fs_scandir_next(handle)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
if type == "directory" and not name:match("^%.") and not name:match("node_modules") then
|
||||
table.insert(main_dirs, name .. "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #main_dirs > 0 then
|
||||
table.sort(main_dirs)
|
||||
table.insert(lines, " Main dirs: " .. table.concat(main_dirs, ", "))
|
||||
end
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,754 +0,0 @@
|
||||
---@mod codetyper.agent.agentic Agentic loop with proper tool calling
|
||||
---@brief [[
|
||||
--- Full agentic system that handles multi-file changes via tool calling.
|
||||
--- Multi-file agent system with tool orchestration.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class AgenticMessage
|
||||
---@field role "system"|"user"|"assistant"|"tool"
|
||||
---@field content string|table
|
||||
---@field tool_calls? table[] For assistant messages with tool calls
|
||||
---@field tool_call_id? string For tool result messages
|
||||
---@field name? string Tool name for tool results
|
||||
|
||||
---@class AgenticToolCall
|
||||
---@field id string Unique tool call ID
|
||||
---@field type "function"
|
||||
---@field function {name: string, arguments: string|table}
|
||||
|
||||
---@class AgenticOpts
|
||||
---@field task string The task to accomplish
|
||||
---@field files? string[] Initial files to include as context
|
||||
---@field agent? string Agent name to use (default: "coder")
|
||||
---@field model? string Model override
|
||||
---@field max_iterations? number Max tool call rounds (default: 20)
|
||||
---@field on_message? fun(msg: AgenticMessage) Called for each message
|
||||
---@field on_tool_start? fun(name: string, args: table) Called before tool execution
|
||||
---@field on_tool_end? fun(name: string, result: any, error: string|nil) Called after tool execution
|
||||
---@field on_file_change? fun(path: string, action: string) Called when file is modified
|
||||
---@field on_complete? fun(result: string|nil, error: string|nil) Called when done
|
||||
---@field on_status? fun(status: string) Status updates
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
|
||||
--- Load agent definition
|
||||
---@param name string Agent name
|
||||
---@return table|nil agent definition
|
||||
local function load_agent(name)
|
||||
local agents_dir = vim.fn.getcwd() .. "/.coder/agents"
|
||||
local agent_file = agents_dir .. "/" .. name .. ".md"
|
||||
|
||||
-- Check if custom agent exists
|
||||
if vim.fn.filereadable(agent_file) == 1 then
|
||||
local content = table.concat(vim.fn.readfile(agent_file), "\n")
|
||||
-- Parse frontmatter and content
|
||||
local frontmatter = {}
|
||||
local body = content
|
||||
|
||||
local fm_match = content:match("^%-%-%-\n(.-)%-%-%-\n(.*)$")
|
||||
if fm_match then
|
||||
-- Parse YAML-like frontmatter
|
||||
for line in content:match("^%-%-%-\n(.-)%-%-%-"):gmatch("[^\n]+") do
|
||||
local key, value = line:match("^(%w+):%s*(.+)$")
|
||||
if key and value then
|
||||
frontmatter[key] = value
|
||||
end
|
||||
end
|
||||
body = content:match("%-%-%-\n.-%-%-%-%s*\n(.*)$") or content
|
||||
end
|
||||
|
||||
return {
|
||||
name = name,
|
||||
description = frontmatter.description or "Custom agent: " .. name,
|
||||
system_prompt = body,
|
||||
tools = frontmatter.tools and vim.split(frontmatter.tools, ",") or nil,
|
||||
model = frontmatter.model,
|
||||
}
|
||||
end
|
||||
|
||||
-- Built-in agents
|
||||
local builtin_agents = require("codetyper.prompts.agents.personas").builtin
|
||||
|
||||
return builtin_agents[name]
|
||||
end
|
||||
|
||||
--- Load rules from .coder/rules/
|
||||
---@return string Combined rules content
|
||||
local function load_rules()
|
||||
local rules_dir = vim.fn.getcwd() .. "/.coder/rules"
|
||||
local rules = {}
|
||||
|
||||
if vim.fn.isdirectory(rules_dir) == 1 then
|
||||
local files = vim.fn.glob(rules_dir .. "/*.md", false, true)
|
||||
for _, file in ipairs(files) do
|
||||
local content = table.concat(vim.fn.readfile(file), "\n")
|
||||
local filename = vim.fn.fnamemodify(file, ":t:r")
|
||||
table.insert(rules, string.format("## Rule: %s\n%s", filename, content))
|
||||
end
|
||||
end
|
||||
|
||||
if #rules > 0 then
|
||||
return "\n\n# Project Rules\n" .. table.concat(rules, "\n\n")
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
--- Build messages array for API request
|
||||
---@param history AgenticMessage[]
|
||||
---@param provider string "openai"|"claude"
|
||||
---@return table[] Formatted messages
|
||||
local function build_messages(history, provider)
|
||||
local messages = {}
|
||||
|
||||
for _, msg in ipairs(history) do
|
||||
if msg.role == "system" then
|
||||
if provider == "claude" then
|
||||
-- Claude uses system parameter, not message
|
||||
-- Skip system messages in array
|
||||
else
|
||||
table.insert(messages, {
|
||||
role = "system",
|
||||
content = msg.content,
|
||||
})
|
||||
end
|
||||
elseif msg.role == "user" then
|
||||
table.insert(messages, {
|
||||
role = "user",
|
||||
content = msg.content,
|
||||
})
|
||||
elseif msg.role == "assistant" then
|
||||
local message = {
|
||||
role = "assistant",
|
||||
content = msg.content,
|
||||
}
|
||||
if msg.tool_calls then
|
||||
message.tool_calls = msg.tool_calls
|
||||
if provider == "claude" then
|
||||
-- Claude format: content is array of blocks
|
||||
message.content = {}
|
||||
if msg.content and msg.content ~= "" then
|
||||
table.insert(message.content, {
|
||||
type = "text",
|
||||
text = msg.content,
|
||||
})
|
||||
end
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
table.insert(message.content, {
|
||||
type = "tool_use",
|
||||
id = tc.id,
|
||||
name = tc["function"].name,
|
||||
input = type(tc["function"].arguments) == "string"
|
||||
and vim.json.decode(tc["function"].arguments)
|
||||
or tc["function"].arguments,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(messages, message)
|
||||
elseif msg.role == "tool" then
|
||||
if provider == "claude" then
|
||||
table.insert(messages, {
|
||||
role = "user",
|
||||
content = {
|
||||
{
|
||||
type = "tool_result",
|
||||
tool_use_id = msg.tool_call_id,
|
||||
content = msg.content,
|
||||
},
|
||||
},
|
||||
})
|
||||
else
|
||||
table.insert(messages, {
|
||||
role = "tool",
|
||||
tool_call_id = msg.tool_call_id,
|
||||
content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content),
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return messages
|
||||
end
|
||||
|
||||
--- Build tools array for API request
|
||||
---@param tool_names string[] Tool names to include
|
||||
---@param provider string "openai"|"claude"
|
||||
---@return table[] Formatted tools
|
||||
local function build_tools(tool_names, provider)
|
||||
local tools_mod = require("codetyper.core.tools")
|
||||
local tools = {}
|
||||
|
||||
for _, name in ipairs(tool_names) do
|
||||
local tool = tools_mod.get(name)
|
||||
if tool then
|
||||
local properties = {}
|
||||
local required = {}
|
||||
|
||||
for _, param in ipairs(tool.params or {}) do
|
||||
properties[param.name] = {
|
||||
type = param.type == "integer" and "number" or param.type,
|
||||
description = param.description,
|
||||
}
|
||||
if not param.optional then
|
||||
table.insert(required, param.name)
|
||||
end
|
||||
end
|
||||
|
||||
local description = type(tool.description) == "function" and tool.description() or tool.description
|
||||
|
||||
if provider == "claude" then
|
||||
table.insert(tools, {
|
||||
name = tool.name,
|
||||
description = description,
|
||||
input_schema = {
|
||||
type = "object",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
})
|
||||
else
|
||||
table.insert(tools, {
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = tool.name,
|
||||
description = description,
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = properties,
|
||||
required = required,
|
||||
},
|
||||
},
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return tools
|
||||
end
|
||||
|
||||
--- Execute a tool call
|
||||
---@param tool_call AgenticToolCall
|
||||
---@param opts AgenticOpts
|
||||
---@return string result
|
||||
---@return string|nil error
|
||||
local function execute_tool(tool_call, opts)
|
||||
local tools_mod = require("codetyper.core.tools")
|
||||
local name = tool_call["function"].name
|
||||
local args = tool_call["function"].arguments
|
||||
|
||||
-- Parse arguments if string
|
||||
if type(args) == "string" then
|
||||
local ok, parsed = pcall(vim.json.decode, args)
|
||||
if ok then
|
||||
args = parsed
|
||||
else
|
||||
return "", "Failed to parse tool arguments: " .. args
|
||||
end
|
||||
end
|
||||
|
||||
-- Notify tool start
|
||||
if opts.on_tool_start then
|
||||
opts.on_tool_start(name, args)
|
||||
end
|
||||
|
||||
if opts.on_status then
|
||||
opts.on_status("Executing: " .. name)
|
||||
end
|
||||
|
||||
-- Execute the tool
|
||||
local tool = tools_mod.get(name)
|
||||
if not tool then
|
||||
local err = "Unknown tool: " .. name
|
||||
if opts.on_tool_end then
|
||||
opts.on_tool_end(name, nil, err)
|
||||
end
|
||||
return "", err
|
||||
end
|
||||
|
||||
local result, err = tool.func(args, {
|
||||
on_log = function(msg)
|
||||
if opts.on_status then
|
||||
opts.on_status(msg)
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
||||
-- Notify tool end
|
||||
if opts.on_tool_end then
|
||||
opts.on_tool_end(name, result, err)
|
||||
end
|
||||
|
||||
-- Track file changes
|
||||
if opts.on_file_change and (name == "write" or name == "edit") and not err then
|
||||
opts.on_file_change(args.path, name == "write" and "created" or "modified")
|
||||
end
|
||||
|
||||
if err then
|
||||
return "", err
|
||||
end
|
||||
|
||||
return type(result) == "string" and result or vim.json.encode(result), nil
|
||||
end
|
||||
|
||||
--- Parse tool calls from LLM response (unified Claude-like format)
|
||||
---@param response table Raw API response in unified format
|
||||
---@param provider string Provider name (unused, kept for signature compatibility)
|
||||
---@return AgenticToolCall[]
|
||||
local function parse_tool_calls(response, provider)
|
||||
local tool_calls = {}
|
||||
|
||||
-- Unified format: content array with tool_use blocks
|
||||
local content = response.content or {}
|
||||
for _, block in ipairs(content) do
|
||||
if block.type == "tool_use" then
|
||||
-- OpenAI expects arguments as JSON string, not table
|
||||
local args = block.input
|
||||
if type(args) == "table" then
|
||||
args = vim.json.encode(args)
|
||||
end
|
||||
|
||||
table.insert(tool_calls, {
|
||||
id = block.id or utils.generate_id("call"),
|
||||
type = "function",
|
||||
["function"] = {
|
||||
name = block.name,
|
||||
arguments = args,
|
||||
},
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return tool_calls
|
||||
end
|
||||
|
||||
--- Extract text content from response (unified Claude-like format)
|
||||
---@param response table Raw API response in unified format
|
||||
---@param provider string Provider name (unused, kept for signature compatibility)
|
||||
---@return string
|
||||
local function extract_content(response, provider)
|
||||
local parts = {}
|
||||
for _, block in ipairs(response.content or {}) do
|
||||
if block.type == "text" then
|
||||
table.insert(parts, block.text)
|
||||
end
|
||||
end
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
--- Check if response indicates completion (unified Claude-like format)
|
||||
---@param response table Raw API response in unified format
|
||||
---@param provider string Provider name (unused, kept for signature compatibility)
|
||||
---@return boolean
|
||||
local function is_complete(response, provider)
|
||||
return response.stop_reason == "end_turn"
|
||||
end
|
||||
|
||||
--- Make API request to LLM with native tool calling support
|
||||
---@param messages table[] Formatted messages
|
||||
---@param tools table[] Formatted tools
|
||||
---@param system_prompt string System prompt
|
||||
---@param provider string "openai"|"claude"|"copilot"
|
||||
---@param model string Model name
|
||||
---@param callback fun(response: table|nil, error: string|nil)
|
||||
local function call_llm(messages, tools, system_prompt, provider, model, callback)
|
||||
local context = {
|
||||
language = "lua",
|
||||
file_content = "",
|
||||
prompt_type = "agent",
|
||||
project_root = vim.fn.getcwd(),
|
||||
cwd = vim.fn.getcwd(),
|
||||
}
|
||||
|
||||
-- Use native tool calling APIs
|
||||
if provider == "copilot" then
|
||||
local client = require("codetyper.core.llm.copilot")
|
||||
|
||||
-- Copilot's generate_with_tools expects messages in a specific format
|
||||
-- Convert to the format it expects
|
||||
local converted_messages = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg.role ~= "system" then
|
||||
table.insert(converted_messages, msg)
|
||||
end
|
||||
end
|
||||
|
||||
client.generate_with_tools(converted_messages, context, tools, function(response, err)
|
||||
if err then
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Response is already in Claude-like format from the provider
|
||||
-- Convert to our internal format
|
||||
local result = {
|
||||
content = {},
|
||||
stop_reason = "end_turn",
|
||||
}
|
||||
|
||||
if response and response.content then
|
||||
for _, block in ipairs(response.content) do
|
||||
if block.type == "text" then
|
||||
table.insert(result.content, { type = "text", text = block.text })
|
||||
elseif block.type == "tool_use" then
|
||||
table.insert(result.content, {
|
||||
type = "tool_use",
|
||||
id = block.id or utils.generate_id("call"),
|
||||
name = block.name,
|
||||
input = block.input,
|
||||
})
|
||||
result.stop_reason = "tool_use"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
callback(result, nil)
|
||||
end)
|
||||
elseif provider == "openai" then
|
||||
local client = require("codetyper.core.llm.openai")
|
||||
|
||||
-- OpenAI's generate_with_tools
|
||||
local converted_messages = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg.role ~= "system" then
|
||||
table.insert(converted_messages, msg)
|
||||
end
|
||||
end
|
||||
|
||||
client.generate_with_tools(converted_messages, context, tools, function(response, err)
|
||||
if err then
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Response is already in Claude-like format from the provider
|
||||
local result = {
|
||||
content = {},
|
||||
stop_reason = "end_turn",
|
||||
}
|
||||
|
||||
if response and response.content then
|
||||
for _, block in ipairs(response.content) do
|
||||
if block.type == "text" then
|
||||
table.insert(result.content, { type = "text", text = block.text })
|
||||
elseif block.type == "tool_use" then
|
||||
table.insert(result.content, {
|
||||
type = "tool_use",
|
||||
id = block.id or utils.generate_id("call"),
|
||||
name = block.name,
|
||||
input = block.input,
|
||||
})
|
||||
result.stop_reason = "tool_use"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
callback(result, nil)
|
||||
end)
|
||||
elseif provider == "ollama" then
|
||||
local client = require("codetyper.core.llm.ollama")
|
||||
|
||||
-- Ollama's generate_with_tools (text-based tool calling)
|
||||
local converted_messages = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg.role ~= "system" then
|
||||
table.insert(converted_messages, msg)
|
||||
end
|
||||
end
|
||||
|
||||
client.generate_with_tools(converted_messages, context, tools, function(response, err)
|
||||
if err then
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Response is already in Claude-like format from the provider
|
||||
callback(response, nil)
|
||||
end)
|
||||
else
|
||||
-- Fallback for other providers (ollama, etc.) - use text-based parsing
|
||||
local client = require("codetyper.core.llm." .. provider)
|
||||
|
||||
-- Build prompt from messages
|
||||
local prompts = require("codetyper.prompts.agents")
|
||||
local prompt_parts = {}
|
||||
for _, msg in ipairs(messages) do
|
||||
if msg.role == "user" then
|
||||
local content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content)
|
||||
table.insert(prompt_parts, prompts.text_user_prefix .. content)
|
||||
elseif msg.role == "assistant" then
|
||||
local content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content)
|
||||
table.insert(prompt_parts, prompts.text_assistant_prefix .. content)
|
||||
end
|
||||
end
|
||||
|
||||
-- Add tool descriptions to prompt for text-based providers
|
||||
local tool_desc = require("codetyper.prompts.agents").tool_instructions_text
|
||||
for _, tool in ipairs(tools) do
|
||||
local name = tool.name or (tool["function"] and tool["function"].name)
|
||||
local desc = tool.description or (tool["function"] and tool["function"].description)
|
||||
if name then
|
||||
tool_desc = tool_desc .. string.format("- **%s**: %s\n", name, desc or "")
|
||||
end
|
||||
end
|
||||
|
||||
context.file_content = system_prompt .. tool_desc
|
||||
|
||||
client.generate(table.concat(prompt_parts, "\n\n"), context, function(response, err)
|
||||
if err then
|
||||
callback(nil, err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Parse response for tool calls (text-based fallback)
|
||||
local result = {
|
||||
content = {},
|
||||
stop_reason = "end_turn",
|
||||
}
|
||||
|
||||
-- Extract text content
|
||||
local text_content = response
|
||||
|
||||
-- Try to extract JSON tool calls from response
|
||||
local json_match = response:match("```json%s*(%b{})%s*```")
|
||||
if json_match then
|
||||
local ok, parsed = pcall(vim.json.decode, json_match)
|
||||
if ok and parsed.tool then
|
||||
table.insert(result.content, {
|
||||
type = "tool_use",
|
||||
id = utils.generate_id("call"),
|
||||
name = parsed.tool,
|
||||
input = parsed.arguments or {},
|
||||
})
|
||||
text_content = response:gsub("```json.-```", ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
result.stop_reason = "tool_use"
|
||||
end
|
||||
end
|
||||
|
||||
if text_content and text_content ~= "" then
|
||||
table.insert(result.content, 1, { type = "text", text = text_content })
|
||||
end
|
||||
|
||||
callback(result, nil)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
--- Run the agentic loop
|
||||
---@param opts AgenticOpts
|
||||
function M.run(opts)
|
||||
-- Load agent
|
||||
local agent = load_agent(opts.agent or "coder")
|
||||
if not agent then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, "Unknown agent: " .. (opts.agent or "coder"))
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Load rules
|
||||
local rules = load_rules()
|
||||
|
||||
-- Build system prompt
|
||||
local system_prompt = agent.system_prompt .. rules
|
||||
|
||||
-- Initialize message history
|
||||
---@type AgenticMessage[]
|
||||
local history = {
|
||||
{ role = "system", content = system_prompt },
|
||||
}
|
||||
|
||||
-- Add initial file context if provided
|
||||
if opts.files and #opts.files > 0 then
|
||||
local file_context = require("codetyper.prompts.agents").format_file_context(opts.files)
|
||||
table.insert(history, { role = "user", content = file_context })
|
||||
table.insert(history, { role = "assistant", content = "I've reviewed the provided files. What would you like me to do?" })
|
||||
end
|
||||
|
||||
-- Add the task
|
||||
table.insert(history, { role = "user", content = opts.task })
|
||||
|
||||
-- Determine provider
|
||||
local config = require("codetyper").get_config()
|
||||
local provider = config.llm.provider or "copilot"
|
||||
-- Note: Ollama has its own handler in call_llm, don't change it
|
||||
|
||||
-- Get tools for this agent
|
||||
local tool_names = agent.tools or { "view", "edit", "write", "grep", "glob", "bash" }
|
||||
|
||||
-- Ensure tools are loaded
|
||||
local tools_mod = require("codetyper.core.tools")
|
||||
tools_mod.setup()
|
||||
|
||||
-- Build tools for API
|
||||
local tools = build_tools(tool_names, provider)
|
||||
|
||||
-- Iteration tracking
|
||||
local iteration = 0
|
||||
local max_iterations = opts.max_iterations or 20
|
||||
|
||||
--- Process one iteration
|
||||
local function process_iteration()
|
||||
iteration = iteration + 1
|
||||
|
||||
if iteration > max_iterations then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, "Max iterations reached")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if opts.on_status then
|
||||
opts.on_status(string.format("Thinking... (iteration %d)", iteration))
|
||||
end
|
||||
|
||||
-- Build messages for API
|
||||
local messages = build_messages(history, provider)
|
||||
|
||||
-- Call LLM
|
||||
call_llm(messages, tools, system_prompt, provider, opts.model, function(response, err)
|
||||
if err then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(nil, err)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Extract content and tool calls
|
||||
local content = extract_content(response, provider)
|
||||
local tool_calls = parse_tool_calls(response, provider)
|
||||
|
||||
-- Add assistant message to history
|
||||
local assistant_msg = {
|
||||
role = "assistant",
|
||||
content = content,
|
||||
tool_calls = #tool_calls > 0 and tool_calls or nil,
|
||||
}
|
||||
table.insert(history, assistant_msg)
|
||||
|
||||
if opts.on_message then
|
||||
opts.on_message(assistant_msg)
|
||||
end
|
||||
|
||||
-- Process tool calls if any
|
||||
if #tool_calls > 0 then
|
||||
for _, tc in ipairs(tool_calls) do
|
||||
local result, tool_err = execute_tool(tc, opts)
|
||||
|
||||
-- Add tool result to history
|
||||
local tool_msg = {
|
||||
role = "tool",
|
||||
tool_call_id = tc.id,
|
||||
name = tc["function"].name,
|
||||
content = tool_err or result,
|
||||
}
|
||||
table.insert(history, tool_msg)
|
||||
|
||||
if opts.on_message then
|
||||
opts.on_message(tool_msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- Continue the loop
|
||||
vim.schedule(process_iteration)
|
||||
else
|
||||
-- No tool calls - check if complete
|
||||
if is_complete(response, provider) or content ~= "" then
|
||||
if opts.on_complete then
|
||||
opts.on_complete(content, nil)
|
||||
end
|
||||
else
|
||||
-- Continue if not explicitly complete
|
||||
vim.schedule(process_iteration)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Start the loop
|
||||
process_iteration()
|
||||
end
|
||||
|
||||
--- Create default agent files in .coder/agents/
|
||||
function M.init_agents_dir()
|
||||
local agents_dir = vim.fn.getcwd() .. "/.coder/agents"
|
||||
vim.fn.mkdir(agents_dir, "p")
|
||||
|
||||
-- Create example agent
|
||||
local example_agent = require("codetyper.prompts.agents.templates").agent
|
||||
|
||||
local example_path = agents_dir .. "/example.md"
|
||||
if vim.fn.filereadable(example_path) ~= 1 then
|
||||
vim.fn.writefile(vim.split(example_agent, "\n"), example_path)
|
||||
end
|
||||
|
||||
return agents_dir
|
||||
end
|
||||
|
||||
--- Create default rules in .coder/rules/
|
||||
function M.init_rules_dir()
|
||||
local rules_dir = vim.fn.getcwd() .. "/.coder/rules"
|
||||
vim.fn.mkdir(rules_dir, "p")
|
||||
|
||||
-- Create example rule
|
||||
local example_rule = require("codetyper.prompts.agents.templates").rule
|
||||
|
||||
local example_path = rules_dir .. "/code-style.md"
|
||||
if vim.fn.filereadable(example_path) ~= 1 then
|
||||
vim.fn.writefile(vim.split(example_rule, "\n"), example_path)
|
||||
end
|
||||
|
||||
return rules_dir
|
||||
end
|
||||
|
||||
--- Initialize both agents and rules directories
|
||||
function M.init()
|
||||
M.init_agents_dir()
|
||||
M.init_rules_dir()
|
||||
end
|
||||
|
||||
--- List available agents
|
||||
---@return table[] List of {name, description, builtin}
|
||||
function M.list_agents()
|
||||
local agents = {}
|
||||
|
||||
-- Built-in agents
|
||||
local personas = require("codetyper.prompts.agents.personas").builtin
|
||||
local builtins = vim.tbl_keys(personas)
|
||||
table.sort(builtins)
|
||||
|
||||
for _, name in ipairs(builtins) do
|
||||
local agent = load_agent(name)
|
||||
if agent then
|
||||
table.insert(agents, {
|
||||
name = agent.name,
|
||||
description = agent.description,
|
||||
builtin = true,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Custom agents from .coder/agents/
|
||||
local agents_dir = vim.fn.getcwd() .. "/.coder/agents"
|
||||
if vim.fn.isdirectory(agents_dir) == 1 then
|
||||
local files = vim.fn.glob(agents_dir .. "/*.md", false, true)
|
||||
for _, file in ipairs(files) do
|
||||
local name = vim.fn.fnamemodify(file, ":t:r")
|
||||
if not vim.tbl_contains(builtins, name) then
|
||||
local agent = load_agent(name)
|
||||
if agent then
|
||||
table.insert(agents, {
|
||||
name = agent.name,
|
||||
description = agent.description,
|
||||
builtin = false,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return agents
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,455 +0,0 @@
|
||||
---@mod codetyper.agent Agent orchestration for Codetyper.nvim
|
||||
---
|
||||
--- Manages the agentic conversation loop with tool execution.
|
||||
|
||||
local M = {}
|
||||
|
||||
local tools = require("codetyper.core.tools")
|
||||
local executor = require("codetyper.core.scheduler.executor")
|
||||
local parser = require("codetyper.core.llm.parser")
|
||||
local diff = require("codetyper.core.diff.diff")
|
||||
local diff_review = require("codetyper.adapters.nvim.ui.diff_review")
|
||||
local resume = require("codetyper.core.scheduler.resume")
|
||||
local utils = require("codetyper.support.utils")
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
|
||||
---@class AgentState
|
||||
---@field conversation table[] Message history for multi-turn
|
||||
---@field pending_tool_results table[] Results waiting to be sent back
|
||||
---@field is_running boolean Whether agent loop is active
|
||||
---@field max_iterations number Maximum tool call iterations
|
||||
|
||||
local state = {
|
||||
conversation = {},
|
||||
pending_tool_results = {},
|
||||
is_running = false,
|
||||
max_iterations = 25, -- Increased for complex tasks (env setup, tests, fixes)
|
||||
current_iteration = 0,
|
||||
original_prompt = "", -- Store for resume functionality
|
||||
current_context = nil, -- Store context for resume
|
||||
current_callbacks = nil, -- Store callbacks for continue
|
||||
}
|
||||
|
||||
---@class AgentCallbacks
|
||||
---@field on_text fun(text: string) Called when text content is received
|
||||
---@field on_tool_start fun(name: string) Called when a tool starts
|
||||
---@field on_tool_result fun(name: string, result: string) Called when a tool completes
|
||||
---@field on_complete fun() Called when agent finishes
|
||||
---@field on_error fun(err: string) Called on error
|
||||
|
||||
--- Reset agent state for new conversation
|
||||
function M.reset()
|
||||
state.conversation = {}
|
||||
state.pending_tool_results = {}
|
||||
state.is_running = false
|
||||
state.current_iteration = 0
|
||||
-- Clear collected diffs
|
||||
diff_review.clear()
|
||||
end
|
||||
|
||||
--- Check if agent is currently running
|
||||
---@return boolean
|
||||
function M.is_running()
|
||||
return state.is_running
|
||||
end
|
||||
|
||||
--- Stop the agent
|
||||
function M.stop()
|
||||
state.is_running = false
|
||||
utils.notify("Agent stopped")
|
||||
end
|
||||
|
||||
--- Main agent entry point
|
||||
---@param prompt string User's request
|
||||
---@param context table File context
|
||||
---@param callbacks AgentCallbacks Callback functions
|
||||
function M.run(prompt, context, callbacks)
|
||||
if state.is_running then
|
||||
callbacks.on_error("Agent is already running")
|
||||
return
|
||||
end
|
||||
|
||||
logs.info("Starting agent run")
|
||||
logs.debug("Prompt length: " .. #prompt .. " chars")
|
||||
|
||||
state.is_running = true
|
||||
state.current_iteration = 0
|
||||
state.original_prompt = prompt
|
||||
state.current_context = context
|
||||
state.current_callbacks = callbacks
|
||||
|
||||
-- Add user message to conversation
|
||||
table.insert(state.conversation, {
|
||||
role = "user",
|
||||
content = prompt,
|
||||
})
|
||||
|
||||
-- Start the agent loop
|
||||
M.agent_loop(context, callbacks)
|
||||
end
|
||||
|
||||
--- The core agent loop
|
||||
---@param context table File context
|
||||
---@param callbacks AgentCallbacks
|
||||
function M.agent_loop(context, callbacks)
|
||||
if not state.is_running then
|
||||
callbacks.on_complete()
|
||||
return
|
||||
end
|
||||
|
||||
state.current_iteration = state.current_iteration + 1
|
||||
logs.info(string.format("Agent loop iteration %d/%d", state.current_iteration, state.max_iterations))
|
||||
|
||||
if state.current_iteration > state.max_iterations then
|
||||
logs.info("Max iterations reached, asking user to continue or stop")
|
||||
-- Ask user if they want to continue
|
||||
M.prompt_continue(context, callbacks)
|
||||
return
|
||||
end
|
||||
|
||||
local llm = require("codetyper.core.llm")
|
||||
local client = llm.get_client()
|
||||
|
||||
-- Check if client supports tools
|
||||
if not client.generate_with_tools then
|
||||
logs.error("Provider does not support agent mode")
|
||||
callbacks.on_error("Current LLM provider does not support agent mode")
|
||||
state.is_running = false
|
||||
return
|
||||
end
|
||||
|
||||
logs.thinking("Calling LLM with " .. #state.conversation .. " messages...")
|
||||
|
||||
-- Generate with tools enabled
|
||||
-- Ensure tools are loaded and get definitions
|
||||
tools.setup()
|
||||
local tool_defs = tools.to_openai_format()
|
||||
|
||||
client.generate_with_tools(state.conversation, context, tool_defs, function(response, err)
|
||||
if err then
|
||||
state.is_running = false
|
||||
callbacks.on_error(err)
|
||||
return
|
||||
end
|
||||
|
||||
-- Parse response based on provider
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
local parsed
|
||||
|
||||
-- Copilot uses Claude-like response format
|
||||
if config.llm.provider == "copilot" then
|
||||
parsed = parser.parse_claude_response(response)
|
||||
table.insert(state.conversation, {
|
||||
role = "assistant",
|
||||
content = parsed.text or "",
|
||||
tool_calls = parsed.tool_calls,
|
||||
_raw_content = response.content,
|
||||
})
|
||||
else
|
||||
-- For Ollama, response is the text directly
|
||||
if type(response) == "string" then
|
||||
parsed = parser.parse_ollama_response(response)
|
||||
else
|
||||
parsed = parser.parse_ollama_response(response.response or "")
|
||||
end
|
||||
-- Add assistant response to conversation
|
||||
table.insert(state.conversation, {
|
||||
role = "assistant",
|
||||
content = parsed.text,
|
||||
tool_calls = parsed.tool_calls,
|
||||
})
|
||||
end
|
||||
|
||||
-- Display any text content
|
||||
if parsed.text and parsed.text ~= "" then
|
||||
local clean_text = parser.clean_text(parsed.text)
|
||||
if clean_text ~= "" then
|
||||
callbacks.on_text(clean_text)
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for tool calls
|
||||
if #parsed.tool_calls > 0 then
|
||||
logs.info(string.format("Processing %d tool call(s)", #parsed.tool_calls))
|
||||
-- Process tool calls sequentially
|
||||
M.process_tool_calls(parsed.tool_calls, 1, context, callbacks)
|
||||
else
|
||||
-- No more tool calls, agent is done
|
||||
logs.info("No tool calls, finishing agent loop")
|
||||
state.is_running = false
|
||||
callbacks.on_complete()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Process tool calls one at a time
|
||||
---@param tool_calls table[] List of tool calls
|
||||
---@param index number Current index
|
||||
---@param context table File context
|
||||
---@param callbacks AgentCallbacks
|
||||
function M.process_tool_calls(tool_calls, index, context, callbacks)
|
||||
if not state.is_running then
|
||||
callbacks.on_complete()
|
||||
return
|
||||
end
|
||||
|
||||
if index > #tool_calls then
|
||||
-- All tools processed, continue agent loop with results
|
||||
M.continue_with_results(context, callbacks)
|
||||
return
|
||||
end
|
||||
|
||||
local tool_call = tool_calls[index]
|
||||
callbacks.on_tool_start(tool_call.name)
|
||||
|
||||
executor.execute(tool_call.name, tool_call.parameters, function(result)
|
||||
if result.requires_approval then
|
||||
logs.tool(tool_call.name, "approval", "Waiting for user approval")
|
||||
-- Show diff preview and wait for user decision
|
||||
local show_fn
|
||||
if result.diff_data.operation == "bash" then
|
||||
show_fn = function(_, cb)
|
||||
diff.show_bash_approval(result.diff_data.modified:gsub("^%$ ", ""), cb)
|
||||
end
|
||||
else
|
||||
show_fn = diff.show_diff
|
||||
end
|
||||
|
||||
show_fn(result.diff_data, function(approval_result)
|
||||
-- Handle both old (boolean) and new (table) approval result formats
|
||||
local approved = type(approval_result) == "table" and approval_result.approved or approval_result
|
||||
local permission_level = type(approval_result) == "table" and approval_result.permission_level or nil
|
||||
|
||||
if approved then
|
||||
local log_msg = "User approved"
|
||||
if permission_level == "allow_session" then
|
||||
log_msg = "Allowed for session"
|
||||
elseif permission_level == "allow_list" then
|
||||
log_msg = "Added to allow list"
|
||||
elseif permission_level == "auto" then
|
||||
log_msg = "Auto-approved"
|
||||
end
|
||||
logs.tool(tool_call.name, "approved", log_msg)
|
||||
|
||||
-- Apply the change and collect for review
|
||||
executor.apply_change(result.diff_data, function(apply_result)
|
||||
-- Collect the diff for end-of-session review
|
||||
if result.diff_data.operation ~= "bash" then
|
||||
diff_review.add({
|
||||
path = result.diff_data.path,
|
||||
operation = result.diff_data.operation,
|
||||
original = result.diff_data.original,
|
||||
modified = result.diff_data.modified,
|
||||
approved = true,
|
||||
applied = true,
|
||||
})
|
||||
end
|
||||
|
||||
-- Store result for sending back to LLM
|
||||
table.insert(state.pending_tool_results, {
|
||||
tool_use_id = tool_call.id,
|
||||
name = tool_call.name,
|
||||
result = apply_result.result,
|
||||
})
|
||||
callbacks.on_tool_result(tool_call.name, apply_result.result)
|
||||
-- Process next tool call
|
||||
M.process_tool_calls(tool_calls, index + 1, context, callbacks)
|
||||
end)
|
||||
else
|
||||
logs.tool(tool_call.name, "rejected", "User rejected")
|
||||
-- User rejected
|
||||
table.insert(state.pending_tool_results, {
|
||||
tool_use_id = tool_call.id,
|
||||
name = tool_call.name,
|
||||
result = "User rejected this change",
|
||||
})
|
||||
callbacks.on_tool_result(tool_call.name, "Rejected by user")
|
||||
M.process_tool_calls(tool_calls, index + 1, context, callbacks)
|
||||
end
|
||||
end)
|
||||
else
|
||||
-- No approval needed (read_file), store result immediately
|
||||
table.insert(state.pending_tool_results, {
|
||||
tool_use_id = tool_call.id,
|
||||
name = tool_call.name,
|
||||
result = result.result,
|
||||
})
|
||||
|
||||
-- For read_file, just show a brief confirmation
|
||||
local display_result = result.result
|
||||
if tool_call.name == "read_file" and result.success then
|
||||
display_result = "[Read " .. #result.result .. " bytes]"
|
||||
end
|
||||
callbacks.on_tool_result(tool_call.name, display_result)
|
||||
|
||||
M.process_tool_calls(tool_calls, index + 1, context, callbacks)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Continue the loop after tool execution
|
||||
---@param context table File context
|
||||
---@param callbacks AgentCallbacks
|
||||
function M.continue_with_results(context, callbacks)
|
||||
if #state.pending_tool_results == 0 then
|
||||
state.is_running = false
|
||||
callbacks.on_complete()
|
||||
return
|
||||
end
|
||||
|
||||
-- Build tool results message
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
|
||||
-- Copilot uses OpenAI format for tool results (role: "tool")
|
||||
if config.llm.provider == "copilot" then
|
||||
-- OpenAI-style tool messages - each result is a separate message
|
||||
for _, result in ipairs(state.pending_tool_results) do
|
||||
table.insert(state.conversation, {
|
||||
role = "tool",
|
||||
tool_call_id = result.tool_use_id,
|
||||
content = result.result,
|
||||
})
|
||||
end
|
||||
else
|
||||
-- Ollama format: plain text describing results
|
||||
local result_text = "Tool results:\n"
|
||||
for _, result in ipairs(state.pending_tool_results) do
|
||||
result_text = result_text .. "\n[" .. result.name .. "]: " .. result.result .. "\n"
|
||||
end
|
||||
table.insert(state.conversation, {
|
||||
role = "user",
|
||||
content = result_text,
|
||||
})
|
||||
end
|
||||
|
||||
state.pending_tool_results = {}
|
||||
|
||||
-- Continue the loop
|
||||
M.agent_loop(context, callbacks)
|
||||
end
|
||||
|
||||
--- Get conversation history
|
||||
---@return table[]
|
||||
function M.get_conversation()
|
||||
return state.conversation
|
||||
end
|
||||
|
||||
--- Set max iterations
|
||||
---@param max number Maximum iterations
|
||||
function M.set_max_iterations(max)
|
||||
state.max_iterations = max
|
||||
end
|
||||
|
||||
--- Get the count of collected changes
|
||||
---@return number
|
||||
function M.get_changes_count()
|
||||
return diff_review.count()
|
||||
end
|
||||
|
||||
--- Show the diff review UI for all collected changes
|
||||
function M.show_diff_review()
|
||||
diff_review.open()
|
||||
end
|
||||
|
||||
--- Check if diff review is open
|
||||
---@return boolean
|
||||
function M.is_review_open()
|
||||
return diff_review.is_open()
|
||||
end
|
||||
|
||||
--- Prompt user to continue or stop at max iterations
|
||||
---@param context table File context
|
||||
---@param callbacks AgentCallbacks
|
||||
function M.prompt_continue(context, callbacks)
|
||||
vim.schedule(function()
|
||||
vim.ui.select({ "Continue (25 more iterations)", "Stop and save for later" }, {
|
||||
prompt = string.format("Agent reached %d iterations. Continue?", state.max_iterations),
|
||||
}, function(choice)
|
||||
if choice and choice:match("^Continue") then
|
||||
-- Reset iteration counter and continue
|
||||
state.current_iteration = 0
|
||||
logs.info("User chose to continue, resetting iteration counter")
|
||||
M.agent_loop(context, callbacks)
|
||||
else
|
||||
-- Save state for later resume
|
||||
logs.info("User chose to stop, saving state for resume")
|
||||
resume.save(
|
||||
state.conversation,
|
||||
state.pending_tool_results,
|
||||
state.current_iteration,
|
||||
state.original_prompt
|
||||
)
|
||||
state.is_running = false
|
||||
callbacks.on_text("Agent paused. Use /continue to resume later.")
|
||||
callbacks.on_complete()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Continue a previously stopped agent session
|
||||
---@param callbacks AgentCallbacks
|
||||
---@return boolean Success
|
||||
function M.continue_session(callbacks)
|
||||
if state.is_running then
|
||||
utils.notify("Agent is already running", vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
|
||||
local saved = resume.load()
|
||||
if not saved then
|
||||
utils.notify("No saved agent session to continue", vim.log.levels.WARN)
|
||||
return false
|
||||
end
|
||||
|
||||
logs.info("Resuming agent session")
|
||||
logs.info(string.format("Loaded %d messages, iteration %d", #saved.conversation, saved.iteration))
|
||||
|
||||
-- Restore state
|
||||
state.conversation = saved.conversation
|
||||
state.pending_tool_results = saved.pending_tool_results or {}
|
||||
state.current_iteration = 0 -- Reset for fresh iterations
|
||||
state.original_prompt = saved.original_prompt
|
||||
state.is_running = true
|
||||
state.current_callbacks = callbacks
|
||||
|
||||
-- Build context from current state
|
||||
local llm = require("codetyper.core.llm")
|
||||
local context = {}
|
||||
local current_file = vim.fn.expand("%:p")
|
||||
if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then
|
||||
context = llm.build_context(current_file, "agent")
|
||||
end
|
||||
state.current_context = context
|
||||
|
||||
-- Clear saved state
|
||||
resume.clear()
|
||||
|
||||
-- Add continuation message
|
||||
table.insert(state.conversation, {
|
||||
role = "user",
|
||||
content = "Continue where you left off. Complete the remaining tasks.",
|
||||
})
|
||||
|
||||
-- Continue the loop
|
||||
callbacks.on_text("Resuming agent session...")
|
||||
M.agent_loop(context, callbacks)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Check if there's a saved session to continue
|
||||
---@return boolean
|
||||
function M.has_saved_session()
|
||||
return resume.has_saved_state()
|
||||
end
|
||||
|
||||
--- Get info about saved session
|
||||
---@return table|nil
|
||||
function M.get_saved_session_info()
|
||||
return resume.get_info()
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,425 +0,0 @@
|
||||
---@mod codetyper.agent.linter Linter validation for generated code
|
||||
---@brief [[
|
||||
--- Validates generated code by checking LSP diagnostics after injection.
|
||||
--- Automatically saves the file and waits for LSP to update before checking.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local config_params = require("codetyper.params.agents.linter")
|
||||
local prompts = require("codetyper.prompts.agents.linter")
|
||||
|
||||
--- Configuration
|
||||
local config = config_params.config
|
||||
|
||||
--- Diagnostic results for tracking
|
||||
---@type table<number, table>
|
||||
local validation_results = {}
|
||||
|
||||
--- Configure linter behavior
|
||||
---@param opts table Configuration options
|
||||
function M.configure(opts)
|
||||
for k, v in pairs(opts) do
|
||||
if config[k] ~= nil then
|
||||
config[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Get current configuration
|
||||
---@return table
|
||||
function M.get_config()
|
||||
return vim.deepcopy(config)
|
||||
end
|
||||
|
||||
--- Save buffer if modified
|
||||
---@param bufnr number Buffer number
|
||||
---@return boolean success
|
||||
local function save_buffer(bufnr)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Skip if buffer is not modified
|
||||
if not vim.bo[bufnr].modified then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Skip if buffer has no name (unsaved file)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
if bufname == "" then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Save the buffer
|
||||
local ok, err = pcall(function()
|
||||
vim.api.nvim_buf_call(bufnr, function()
|
||||
vim.cmd("silent! write")
|
||||
end)
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "warning",
|
||||
message = "Failed to save buffer: " .. tostring(err),
|
||||
})
|
||||
end)
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Get LSP diagnostics for a buffer
|
||||
---@param bufnr number Buffer number
|
||||
---@param start_line? number Start line (1-indexed)
|
||||
---@param end_line? number End line (1-indexed)
|
||||
---@return table[] diagnostics List of diagnostics
|
||||
function M.get_diagnostics(bufnr, start_line, end_line)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return {}
|
||||
end
|
||||
|
||||
local all_diagnostics = vim.diagnostic.get(bufnr)
|
||||
local filtered = {}
|
||||
|
||||
for _, diag in ipairs(all_diagnostics) do
|
||||
-- Filter by severity
|
||||
if diag.severity <= config.min_severity then
|
||||
-- Filter by line range if specified
|
||||
if start_line and end_line then
|
||||
local diag_line = diag.lnum + 1 -- Convert to 1-indexed
|
||||
if diag_line >= start_line and diag_line <= end_line then
|
||||
table.insert(filtered, diag)
|
||||
end
|
||||
else
|
||||
table.insert(filtered, diag)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return filtered
|
||||
end
|
||||
|
||||
--- Format a diagnostic for display
|
||||
---@param diag table Diagnostic object
|
||||
---@return string
|
||||
local function format_diagnostic(diag)
|
||||
local severity_names = {
|
||||
[vim.diagnostic.severity.ERROR] = "ERROR",
|
||||
[vim.diagnostic.severity.WARN] = "WARN",
|
||||
[vim.diagnostic.severity.INFO] = "INFO",
|
||||
[vim.diagnostic.severity.HINT] = "HINT",
|
||||
}
|
||||
local severity = severity_names[diag.severity] or "UNKNOWN"
|
||||
local line = diag.lnum + 1
|
||||
local source = diag.source or "lsp"
|
||||
return string.format("[%s] Line %d (%s): %s", severity, line, source, diag.message)
|
||||
end
|
||||
|
||||
--- Check if there are errors in generated code region
|
||||
---@param bufnr number Buffer number
|
||||
---@param start_line number Start line (1-indexed)
|
||||
---@param end_line number End line (1-indexed)
|
||||
---@return table result {has_errors, has_warnings, diagnostics, summary}
|
||||
function M.check_region(bufnr, start_line, end_line)
|
||||
local diagnostics = M.get_diagnostics(bufnr, start_line, end_line)
|
||||
|
||||
local errors = 0
|
||||
local warnings = 0
|
||||
|
||||
for _, diag in ipairs(diagnostics) do
|
||||
if diag.severity == vim.diagnostic.severity.ERROR then
|
||||
errors = errors + 1
|
||||
elseif diag.severity == vim.diagnostic.severity.WARN then
|
||||
warnings = warnings + 1
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
has_errors = errors > 0,
|
||||
has_warnings = warnings > 0,
|
||||
error_count = errors,
|
||||
warning_count = warnings,
|
||||
diagnostics = diagnostics,
|
||||
summary = string.format("%d error(s), %d warning(s)", errors, warnings),
|
||||
}
|
||||
end
|
||||
|
||||
--- Validate code after injection and report issues
|
||||
---@param bufnr number Buffer number
|
||||
---@param start_line? number Start line of injected code (1-indexed)
|
||||
---@param end_line? number End line of injected code (1-indexed)
|
||||
---@param callback? function Callback with (result) when validation completes
|
||||
function M.validate_after_injection(bufnr, start_line, end_line, callback)
|
||||
-- Save the file first
|
||||
if config.auto_save then
|
||||
save_buffer(bufnr)
|
||||
end
|
||||
|
||||
-- Wait for LSP to process changes
|
||||
vim.defer_fn(function()
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
if callback then callback(nil) end
|
||||
return
|
||||
end
|
||||
|
||||
local result
|
||||
if start_line and end_line then
|
||||
result = M.check_region(bufnr, start_line, end_line)
|
||||
else
|
||||
-- Check entire buffer
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
result = M.check_region(bufnr, 1, line_count)
|
||||
end
|
||||
|
||||
-- Store result for this buffer
|
||||
validation_results[bufnr] = {
|
||||
timestamp = os.time(),
|
||||
result = result,
|
||||
start_line = start_line,
|
||||
end_line = end_line,
|
||||
}
|
||||
|
||||
-- Log results
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
if result.has_errors then
|
||||
logs.add({
|
||||
type = "error",
|
||||
message = string.format("Linter found issues: %s", result.summary),
|
||||
})
|
||||
-- Log individual errors
|
||||
for _, diag in ipairs(result.diagnostics) do
|
||||
if diag.severity == vim.diagnostic.severity.ERROR then
|
||||
logs.add({
|
||||
type = "error",
|
||||
message = format_diagnostic(diag),
|
||||
})
|
||||
end
|
||||
end
|
||||
elseif result.has_warnings then
|
||||
logs.add({
|
||||
type = "warning",
|
||||
message = string.format("Linter warnings: %s", result.summary),
|
||||
})
|
||||
else
|
||||
logs.add({
|
||||
type = "success",
|
||||
message = "Linter check passed - no errors or warnings",
|
||||
})
|
||||
end
|
||||
end)
|
||||
|
||||
-- Notify user
|
||||
if result.has_errors then
|
||||
vim.notify(
|
||||
string.format("Generated code has lint errors: %s", result.summary),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
|
||||
-- Offer to fix if configured
|
||||
if config.auto_offer_fix and #result.diagnostics > 0 then
|
||||
M.offer_fix(bufnr, result)
|
||||
end
|
||||
elseif result.has_warnings then
|
||||
vim.notify(
|
||||
string.format("Generated code has warnings: %s", result.summary),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
|
||||
if callback then
|
||||
callback(result)
|
||||
end
|
||||
end, config.diagnostic_delay_ms)
|
||||
end
|
||||
|
||||
--- Offer to fix lint errors using AI
|
||||
---@param bufnr number Buffer number
|
||||
---@param result table Validation result
|
||||
function M.offer_fix(bufnr, result)
|
||||
if not result.has_errors and not result.has_warnings then
|
||||
return
|
||||
end
|
||||
|
||||
-- Build error summary for prompt
|
||||
local error_messages = {}
|
||||
for _, diag in ipairs(result.diagnostics) do
|
||||
table.insert(error_messages, format_diagnostic(diag))
|
||||
end
|
||||
|
||||
vim.ui.select(
|
||||
{ "Yes - Auto-fix with AI", "No - I'll fix manually", "Show errors in quickfix" },
|
||||
{
|
||||
prompt = string.format("Found %d issue(s). Would you like AI to fix them?", #result.diagnostics),
|
||||
},
|
||||
function(choice)
|
||||
if not choice then return end
|
||||
|
||||
if choice:match("^Yes") then
|
||||
M.request_ai_fix(bufnr, result)
|
||||
elseif choice:match("quickfix") then
|
||||
M.show_in_quickfix(bufnr, result)
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
--- Show lint errors in quickfix list
|
||||
---@param bufnr number Buffer number
|
||||
---@param result table Validation result
|
||||
function M.show_in_quickfix(bufnr, result)
|
||||
local qf_items = {}
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
|
||||
for _, diag in ipairs(result.diagnostics) do
|
||||
table.insert(qf_items, {
|
||||
bufnr = bufnr,
|
||||
filename = bufname,
|
||||
lnum = diag.lnum + 1,
|
||||
col = diag.col + 1,
|
||||
text = diag.message,
|
||||
type = diag.severity == vim.diagnostic.severity.ERROR and "E" or "W",
|
||||
})
|
||||
end
|
||||
|
||||
vim.fn.setqflist(qf_items, "r")
|
||||
vim.cmd("copen")
|
||||
end
|
||||
|
||||
--- Request AI to fix lint errors
|
||||
---@param bufnr number Buffer number
|
||||
---@param result table Validation result
|
||||
function M.request_ai_fix(bufnr, result)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return
|
||||
end
|
||||
|
||||
local filepath = vim.api.nvim_buf_get_name(bufnr)
|
||||
|
||||
-- Build fix prompt
|
||||
local error_list = {}
|
||||
for _, diag in ipairs(result.diagnostics) do
|
||||
table.insert(error_list, format_diagnostic(diag))
|
||||
end
|
||||
|
||||
-- Get the affected code region
|
||||
local start_line = result.diagnostics[1] and (result.diagnostics[1].lnum + 1) or 1
|
||||
local end_line = start_line
|
||||
for _, diag in ipairs(result.diagnostics) do
|
||||
local line = diag.lnum + 1
|
||||
if line < start_line then start_line = line end
|
||||
if line > end_line then end_line = line end
|
||||
end
|
||||
|
||||
-- Expand range by a few lines for context
|
||||
start_line = math.max(1, start_line - 5)
|
||||
end_line = math.min(vim.api.nvim_buf_line_count(bufnr), end_line + 5)
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
|
||||
local code_context = table.concat(lines, "\n")
|
||||
|
||||
-- Create fix prompt using inline tag
|
||||
local fix_prompt = string.format(
|
||||
prompts.fix_request,
|
||||
table.concat(error_list, "\n"),
|
||||
start_line,
|
||||
end_line,
|
||||
code_context
|
||||
)
|
||||
|
||||
-- Queue the fix through the scheduler
|
||||
local scheduler = require("codetyper.core.scheduler.scheduler")
|
||||
local queue = require("codetyper.core.events.queue")
|
||||
local patch_mod = require("codetyper.core.diff.patch")
|
||||
|
||||
-- Ensure scheduler is running
|
||||
if not scheduler.status().running then
|
||||
scheduler.start()
|
||||
end
|
||||
|
||||
-- Take snapshot
|
||||
local snapshot = patch_mod.snapshot_buffer(bufnr, {
|
||||
start_line = start_line,
|
||||
end_line = end_line,
|
||||
})
|
||||
|
||||
-- Enqueue fix request
|
||||
queue.enqueue({
|
||||
id = queue.generate_id(),
|
||||
bufnr = bufnr,
|
||||
range = { start_line = start_line, end_line = end_line },
|
||||
timestamp = os.clock(),
|
||||
changedtick = snapshot.changedtick,
|
||||
content_hash = snapshot.content_hash,
|
||||
prompt_content = fix_prompt,
|
||||
target_path = filepath,
|
||||
priority = 1, -- High priority for fixes
|
||||
status = "pending",
|
||||
attempt_count = 0,
|
||||
intent = {
|
||||
type = "fix",
|
||||
action = "replace",
|
||||
confidence = 0.9,
|
||||
},
|
||||
scope_range = { start_line = start_line, end_line = end_line },
|
||||
source = "linter_fix",
|
||||
})
|
||||
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = "Queued AI fix request for lint errors",
|
||||
})
|
||||
end)
|
||||
|
||||
vim.notify("Queued AI fix request for lint errors", vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
--- Get last validation result for a buffer
|
||||
---@param bufnr number Buffer number
|
||||
---@return table|nil result
|
||||
function M.get_last_result(bufnr)
|
||||
return validation_results[bufnr]
|
||||
end
|
||||
|
||||
--- Clear validation results for a buffer
|
||||
---@param bufnr number Buffer number
|
||||
function M.clear_result(bufnr)
|
||||
validation_results[bufnr] = nil
|
||||
end
|
||||
|
||||
--- Check if buffer has any lint errors currently
|
||||
---@param bufnr number Buffer number
|
||||
---@return boolean has_errors
|
||||
function M.has_errors(bufnr)
|
||||
local diagnostics = vim.diagnostic.get(bufnr, {
|
||||
severity = vim.diagnostic.severity.ERROR,
|
||||
})
|
||||
return #diagnostics > 0
|
||||
end
|
||||
|
||||
--- Check if buffer has any lint warnings currently
|
||||
---@param bufnr number Buffer number
|
||||
---@return boolean has_warnings
|
||||
function M.has_warnings(bufnr)
|
||||
local diagnostics = vim.diagnostic.get(bufnr, {
|
||||
severity = { min = vim.diagnostic.severity.WARN },
|
||||
})
|
||||
return #diagnostics > 0
|
||||
end
|
||||
|
||||
--- Validate all buffers with recent changes
|
||||
function M.validate_all_changed()
|
||||
for bufnr, data in pairs(validation_results) do
|
||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
||||
M.validate_after_injection(bufnr, data.start_line, data.end_line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,182 +0,0 @@
|
||||
---@mod codetyper.agent.permissions Permission manager for agent actions
|
||||
---
|
||||
--- Manages permissions for bash commands and file operations with
|
||||
--- allow, allow-session, allow-list, and reject options.
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class PermissionState
|
||||
---@field session_allowed table<string, boolean> Commands allowed for this session
|
||||
---@field allow_list table<string, boolean> Patterns always allowed
|
||||
---@field deny_list table<string, boolean> Patterns always denied
|
||||
|
||||
local params = require("codetyper.params.agents.permissions")
|
||||
|
||||
local state = {
|
||||
session_allowed = {},
|
||||
allow_list = {},
|
||||
deny_list = {},
|
||||
}
|
||||
|
||||
--- Dangerous command patterns that should never be auto-allowed
|
||||
local DANGEROUS_PATTERNS = params.dangerous_patterns
|
||||
|
||||
--- Safe command patterns that can be auto-allowed
|
||||
local SAFE_PATTERNS = params.safe_patterns
|
||||
|
||||
---@alias PermissionLevel "allow"|"allow_session"|"allow_list"|"reject"
|
||||
|
||||
---@class PermissionResult
|
||||
---@field allowed boolean Whether action is allowed
|
||||
---@field reason string Reason for the decision
|
||||
---@field auto boolean Whether this was an automatic decision
|
||||
|
||||
--- Check if a command matches a pattern
|
||||
---@param command string The command to check
|
||||
---@param pattern string The pattern to match
|
||||
---@return boolean
|
||||
local function matches_pattern(command, pattern)
|
||||
return command:match(pattern) ~= nil
|
||||
end
|
||||
|
||||
--- Check if command is dangerous
|
||||
---@param command string The command to check
|
||||
---@return boolean, string|nil dangerous, reason
|
||||
local function is_dangerous(command)
|
||||
for _, pattern in ipairs(DANGEROUS_PATTERNS) do
|
||||
if matches_pattern(command, pattern) then
|
||||
return true, "Matches dangerous pattern: " .. pattern
|
||||
end
|
||||
end
|
||||
return false, nil
|
||||
end
|
||||
|
||||
--- Check if command is safe
|
||||
---@param command string The command to check
|
||||
---@return boolean
|
||||
local function is_safe(command)
|
||||
for _, pattern in ipairs(SAFE_PATTERNS) do
|
||||
if matches_pattern(command, pattern) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Normalize command for comparison (trim, lowercase first word)
|
||||
---@param command string
|
||||
---@return string
|
||||
local function normalize_command(command)
|
||||
return vim.trim(command)
|
||||
end
|
||||
|
||||
--- Check permission for a bash command
|
||||
---@param command string The command to check
|
||||
---@return PermissionResult
|
||||
function M.check_bash_permission(command)
|
||||
local normalized = normalize_command(command)
|
||||
|
||||
-- Check deny list first
|
||||
for pattern, _ in pairs(state.deny_list) do
|
||||
if matches_pattern(normalized, pattern) then
|
||||
return {
|
||||
allowed = false,
|
||||
reason = "Command in deny list",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if command is dangerous
|
||||
local dangerous, reason = is_dangerous(normalized)
|
||||
if dangerous then
|
||||
return {
|
||||
allowed = false,
|
||||
reason = reason,
|
||||
auto = false, -- Require explicit approval for dangerous commands
|
||||
}
|
||||
end
|
||||
|
||||
-- Check session allowed
|
||||
if state.session_allowed[normalized] then
|
||||
return {
|
||||
allowed = true,
|
||||
reason = "Allowed for this session",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
|
||||
-- Check allow list patterns
|
||||
for pattern, _ in pairs(state.allow_list) do
|
||||
if matches_pattern(normalized, pattern) then
|
||||
return {
|
||||
allowed = true,
|
||||
reason = "Matches allow list pattern",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if command is inherently safe
|
||||
if is_safe(normalized) then
|
||||
return {
|
||||
allowed = true,
|
||||
reason = "Safe read-only command",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
|
||||
-- Otherwise, require explicit permission
|
||||
return {
|
||||
allowed = false,
|
||||
reason = "Requires approval",
|
||||
auto = false,
|
||||
}
|
||||
end
|
||||
|
||||
--- Grant permission for a command
|
||||
---@param command string The command
|
||||
---@param level PermissionLevel The permission level
|
||||
function M.grant_permission(command, level)
|
||||
local normalized = normalize_command(command)
|
||||
|
||||
if level == "allow_session" then
|
||||
state.session_allowed[normalized] = true
|
||||
elseif level == "allow_list" then
|
||||
-- Add as pattern (escape special chars for exact match)
|
||||
local pattern = "^" .. vim.pesc(normalized) .. "$"
|
||||
state.allow_list[pattern] = true
|
||||
end
|
||||
end
|
||||
|
||||
--- Add a pattern to the allow list
|
||||
---@param pattern string Lua pattern to allow
|
||||
function M.add_to_allow_list(pattern)
|
||||
state.allow_list[pattern] = true
|
||||
end
|
||||
|
||||
--- Add a pattern to the deny list
|
||||
---@param pattern string Lua pattern to deny
|
||||
function M.add_to_deny_list(pattern)
|
||||
state.deny_list[pattern] = true
|
||||
end
|
||||
|
||||
--- Clear session permissions
|
||||
function M.clear_session()
|
||||
state.session_allowed = {}
|
||||
end
|
||||
|
||||
--- Reset all permissions
|
||||
function M.reset()
|
||||
state.session_allowed = {}
|
||||
state.allow_list = {}
|
||||
state.deny_list = {}
|
||||
end
|
||||
|
||||
--- Get current permission state (for debugging)
|
||||
---@return PermissionState
|
||||
function M.get_state()
|
||||
return vim.deepcopy(state)
|
||||
end
|
||||
|
||||
return M
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,676 +0,0 @@
|
||||
---@mod codetyper.ask.explorer Project exploration for Ask mode
|
||||
---@brief [[
|
||||
--- Performs comprehensive project exploration when explaining a project.
|
||||
--- Shows progress, indexes files, and builds brain context.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
|
||||
---@class ExplorationState
|
||||
---@field is_exploring boolean
|
||||
---@field files_scanned number
|
||||
---@field total_files number
|
||||
---@field current_file string|nil
|
||||
---@field findings table
|
||||
---@field on_log fun(msg: string, level: string)|nil
|
||||
|
||||
local state = {
|
||||
is_exploring = false,
|
||||
files_scanned = 0,
|
||||
total_files = 0,
|
||||
current_file = nil,
|
||||
findings = {},
|
||||
on_log = nil,
|
||||
}
|
||||
|
||||
--- File extensions to analyze
|
||||
local ANALYZABLE_EXTENSIONS = {
|
||||
lua = true,
|
||||
ts = true,
|
||||
tsx = true,
|
||||
js = true,
|
||||
jsx = true,
|
||||
py = true,
|
||||
go = true,
|
||||
rs = true,
|
||||
rb = true,
|
||||
java = true,
|
||||
c = true,
|
||||
cpp = true,
|
||||
h = true,
|
||||
hpp = true,
|
||||
json = true,
|
||||
yaml = true,
|
||||
yml = true,
|
||||
toml = true,
|
||||
md = true,
|
||||
xml = true,
|
||||
}
|
||||
|
||||
--- Directories to skip
|
||||
local SKIP_DIRS = {
|
||||
-- Version control
|
||||
[".git"] = true,
|
||||
[".svn"] = true,
|
||||
[".hg"] = true,
|
||||
|
||||
-- IDE/Editor
|
||||
[".idea"] = true,
|
||||
[".vscode"] = true,
|
||||
[".cursor"] = true,
|
||||
[".cursorignore"] = true,
|
||||
[".claude"] = true,
|
||||
[".zed"] = true,
|
||||
|
||||
-- Project tooling
|
||||
[".coder"] = true,
|
||||
[".github"] = true,
|
||||
[".gitlab"] = true,
|
||||
[".husky"] = true,
|
||||
|
||||
-- Build outputs
|
||||
dist = true,
|
||||
build = true,
|
||||
out = true,
|
||||
target = true,
|
||||
bin = true,
|
||||
obj = true,
|
||||
[".build"] = true,
|
||||
[".output"] = true,
|
||||
|
||||
-- Dependencies
|
||||
node_modules = true,
|
||||
vendor = true,
|
||||
[".vendor"] = true,
|
||||
packages = true,
|
||||
bower_components = true,
|
||||
jspm_packages = true,
|
||||
|
||||
-- Cache/temp
|
||||
[".cache"] = true,
|
||||
[".tmp"] = true,
|
||||
[".temp"] = true,
|
||||
__pycache__ = true,
|
||||
[".pytest_cache"] = true,
|
||||
[".mypy_cache"] = true,
|
||||
[".ruff_cache"] = true,
|
||||
[".tox"] = true,
|
||||
[".nox"] = true,
|
||||
[".eggs"] = true,
|
||||
["*.egg-info"] = true,
|
||||
|
||||
-- Framework specific
|
||||
[".next"] = true,
|
||||
[".nuxt"] = true,
|
||||
[".svelte-kit"] = true,
|
||||
[".vercel"] = true,
|
||||
[".netlify"] = true,
|
||||
[".serverless"] = true,
|
||||
[".turbo"] = true,
|
||||
|
||||
-- Testing/coverage
|
||||
coverage = true,
|
||||
[".nyc_output"] = true,
|
||||
htmlcov = true,
|
||||
|
||||
-- Logs
|
||||
logs = true,
|
||||
log = true,
|
||||
|
||||
-- OS files
|
||||
[".DS_Store"] = true,
|
||||
Thumbs_db = true,
|
||||
}
|
||||
|
||||
--- Files to skip (patterns)
|
||||
local SKIP_FILES = {
|
||||
-- Lock files
|
||||
"package%-lock%.json",
|
||||
"yarn%.lock",
|
||||
"pnpm%-lock%.yaml",
|
||||
"Gemfile%.lock",
|
||||
"Cargo%.lock",
|
||||
"poetry%.lock",
|
||||
"Pipfile%.lock",
|
||||
"composer%.lock",
|
||||
"go%.sum",
|
||||
"flake%.lock",
|
||||
"%.lock$",
|
||||
"%-lock%.json$",
|
||||
"%-lock%.yaml$",
|
||||
|
||||
-- Generated files
|
||||
"%.min%.js$",
|
||||
"%.min%.css$",
|
||||
"%.bundle%.js$",
|
||||
"%.chunk%.js$",
|
||||
"%.map$",
|
||||
"%.d%.ts$",
|
||||
|
||||
-- Binary/media (shouldn't match anyway but be safe)
|
||||
"%.png$",
|
||||
"%.jpg$",
|
||||
"%.jpeg$",
|
||||
"%.gif$",
|
||||
"%.ico$",
|
||||
"%.svg$",
|
||||
"%.woff",
|
||||
"%.ttf$",
|
||||
"%.eot$",
|
||||
"%.pdf$",
|
||||
"%.zip$",
|
||||
"%.tar",
|
||||
"%.gz$",
|
||||
|
||||
-- Config that's not useful
|
||||
"%.env",
|
||||
"%.env%.",
|
||||
}
|
||||
|
||||
--- Log a message during exploration
|
||||
---@param msg string
|
||||
---@param level? string "info"|"debug"|"file"|"progress"
|
||||
local function log(msg, level)
|
||||
level = level or "info"
|
||||
if state.on_log then
|
||||
state.on_log(msg, level)
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if file should be skipped
|
||||
---@param filename string
|
||||
---@return boolean
|
||||
local function should_skip_file(filename)
|
||||
for _, pattern in ipairs(SKIP_FILES) do
|
||||
if filename:match(pattern) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if directory should be skipped
|
||||
---@param dirname string
|
||||
---@return boolean
|
||||
local function should_skip_dir(dirname)
|
||||
-- Direct match
|
||||
if SKIP_DIRS[dirname] then
|
||||
return true
|
||||
end
|
||||
-- Pattern match for .cursor* etc
|
||||
if dirname:match("^%.cursor") then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Get all files in project
|
||||
---@param root string Project root
|
||||
---@return string[] files
|
||||
local function get_project_files(root)
|
||||
local files = {}
|
||||
|
||||
local function scan_dir(dir)
|
||||
local handle = vim.loop.fs_scandir(dir)
|
||||
if not handle then
|
||||
return
|
||||
end
|
||||
|
||||
while true do
|
||||
local name, type = vim.loop.fs_scandir_next(handle)
|
||||
if not name then
|
||||
break
|
||||
end
|
||||
|
||||
local full_path = dir .. "/" .. name
|
||||
|
||||
if type == "directory" then
|
||||
if not should_skip_dir(name) then
|
||||
scan_dir(full_path)
|
||||
end
|
||||
elseif type == "file" then
|
||||
if not should_skip_file(name) then
|
||||
local ext = name:match("%.([^%.]+)$")
|
||||
if ext and ANALYZABLE_EXTENSIONS[ext:lower()] then
|
||||
table.insert(files, full_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
scan_dir(root)
|
||||
return files
|
||||
end
|
||||
|
||||
--- Analyze a single file
|
||||
---@param filepath string
|
||||
---@return table|nil analysis
|
||||
local function analyze_file(filepath)
|
||||
local content = utils.read_file(filepath)
|
||||
if not content or content == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local ext = filepath:match("%.([^%.]+)$") or ""
|
||||
local lines = vim.split(content, "\n")
|
||||
|
||||
local analysis = {
|
||||
path = filepath,
|
||||
extension = ext,
|
||||
lines = #lines,
|
||||
size = #content,
|
||||
imports = {},
|
||||
exports = {},
|
||||
functions = {},
|
||||
classes = {},
|
||||
summary = "",
|
||||
}
|
||||
|
||||
-- Extract key patterns based on file type
|
||||
for i, line in ipairs(lines) do
|
||||
-- Imports/requires
|
||||
local import = line:match('import%s+.*%s+from%s+["\']([^"\']+)["\']')
|
||||
or line:match('require%(["\']([^"\']+)["\']%)')
|
||||
or line:match("from%s+([%w_.]+)%s+import")
|
||||
if import then
|
||||
table.insert(analysis.imports, { source = import, line = i })
|
||||
end
|
||||
|
||||
-- Function definitions
|
||||
local func = line:match("^%s*function%s+([%w_:%.]+)%s*%(")
|
||||
or line:match("^%s*local%s+function%s+([%w_]+)%s*%(")
|
||||
or line:match("^%s*def%s+([%w_]+)%s*%(")
|
||||
or line:match("^%s*func%s+([%w_]+)%s*%(")
|
||||
or line:match("^%s*async%s+function%s+([%w_]+)%s*%(")
|
||||
or line:match("^%s*public%s+.*%s+([%w_]+)%s*%(")
|
||||
if func then
|
||||
table.insert(analysis.functions, { name = func, line = i })
|
||||
end
|
||||
|
||||
-- Class definitions
|
||||
local class = line:match("^%s*class%s+([%w_]+)")
|
||||
or line:match("^%s*public%s+class%s+([%w_]+)")
|
||||
or line:match("^%s*interface%s+([%w_]+)")
|
||||
if class then
|
||||
table.insert(analysis.classes, { name = class, line = i })
|
||||
end
|
||||
|
||||
-- Exports
|
||||
local exp = line:match("^%s*export%s+.*%s+([%w_]+)")
|
||||
or line:match("^%s*module%.exports%s*=")
|
||||
or line:match("^return%s+M")
|
||||
if exp then
|
||||
table.insert(analysis.exports, { name = exp, line = i })
|
||||
end
|
||||
end
|
||||
|
||||
-- Create summary
|
||||
local parts = {}
|
||||
if #analysis.functions > 0 then
|
||||
table.insert(parts, #analysis.functions .. " functions")
|
||||
end
|
||||
if #analysis.classes > 0 then
|
||||
table.insert(parts, #analysis.classes .. " classes")
|
||||
end
|
||||
if #analysis.imports > 0 then
|
||||
table.insert(parts, #analysis.imports .. " imports")
|
||||
end
|
||||
analysis.summary = table.concat(parts, ", ")
|
||||
|
||||
return analysis
|
||||
end
|
||||
|
||||
--- Detect project type from files
|
||||
---@param root string
|
||||
---@return string type, table info
|
||||
local function detect_project_type(root)
|
||||
local info = {
|
||||
name = vim.fn.fnamemodify(root, ":t"),
|
||||
type = "unknown",
|
||||
framework = nil,
|
||||
language = nil,
|
||||
}
|
||||
|
||||
-- Check for common project files
|
||||
if utils.file_exists(root .. "/package.json") then
|
||||
info.type = "node"
|
||||
info.language = "JavaScript/TypeScript"
|
||||
local content = utils.read_file(root .. "/package.json")
|
||||
if content then
|
||||
local ok, pkg = pcall(vim.json.decode, content)
|
||||
if ok then
|
||||
info.name = pkg.name or info.name
|
||||
if pkg.dependencies then
|
||||
if pkg.dependencies.react then
|
||||
info.framework = "React"
|
||||
elseif pkg.dependencies.vue then
|
||||
info.framework = "Vue"
|
||||
elseif pkg.dependencies.next then
|
||||
info.framework = "Next.js"
|
||||
elseif pkg.dependencies.express then
|
||||
info.framework = "Express"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif utils.file_exists(root .. "/pom.xml") then
|
||||
info.type = "maven"
|
||||
info.language = "Java"
|
||||
local content = utils.read_file(root .. "/pom.xml")
|
||||
if content and content:match("spring%-boot") then
|
||||
info.framework = "Spring Boot"
|
||||
end
|
||||
elseif utils.file_exists(root .. "/Cargo.toml") then
|
||||
info.type = "rust"
|
||||
info.language = "Rust"
|
||||
elseif utils.file_exists(root .. "/go.mod") then
|
||||
info.type = "go"
|
||||
info.language = "Go"
|
||||
elseif utils.file_exists(root .. "/requirements.txt") or utils.file_exists(root .. "/pyproject.toml") then
|
||||
info.type = "python"
|
||||
info.language = "Python"
|
||||
elseif utils.file_exists(root .. "/init.lua") or utils.file_exists(root .. "/plugin/") then
|
||||
info.type = "neovim-plugin"
|
||||
info.language = "Lua"
|
||||
end
|
||||
|
||||
return info.type, info
|
||||
end
|
||||
|
||||
--- Build project structure summary
|
||||
---@param files string[]
|
||||
---@param root string
|
||||
---@return table structure
|
||||
local function build_structure(files, root)
|
||||
local structure = {
|
||||
directories = {},
|
||||
by_extension = {},
|
||||
total_files = #files,
|
||||
}
|
||||
|
||||
for _, file in ipairs(files) do
|
||||
local relative = file:gsub("^" .. vim.pesc(root) .. "/", "")
|
||||
local dir = vim.fn.fnamemodify(relative, ":h")
|
||||
local ext = file:match("%.([^%.]+)$") or "unknown"
|
||||
|
||||
structure.directories[dir] = (structure.directories[dir] or 0) + 1
|
||||
structure.by_extension[ext] = (structure.by_extension[ext] or 0) + 1
|
||||
end
|
||||
|
||||
return structure
|
||||
end
|
||||
|
||||
--- Explore project and build context
|
||||
---@param root string Project root
|
||||
---@param on_log fun(msg: string, level: string) Log callback
|
||||
---@param on_complete fun(result: table) Completion callback
|
||||
function M.explore(root, on_log, on_complete)
|
||||
if state.is_exploring then
|
||||
on_log("⚠️ Already exploring...", "warning")
|
||||
return
|
||||
end
|
||||
|
||||
state.is_exploring = true
|
||||
state.on_log = on_log
|
||||
state.findings = {}
|
||||
|
||||
-- Start exploration
|
||||
log("⏺ Exploring project structure...", "info")
|
||||
log("", "info")
|
||||
|
||||
-- Detect project type
|
||||
log(" Detect(Project type)", "progress")
|
||||
local project_type, project_info = detect_project_type(root)
|
||||
log(" ⎿ " .. project_info.language .. " (" .. (project_info.framework or project_type) .. ")", "debug")
|
||||
|
||||
state.findings.project = project_info
|
||||
|
||||
-- Get all files
|
||||
log("", "info")
|
||||
log(" Scan(Project files)", "progress")
|
||||
local files = get_project_files(root)
|
||||
state.total_files = #files
|
||||
log(" ⎿ Found " .. #files .. " analyzable files", "debug")
|
||||
|
||||
-- Build structure
|
||||
local structure = build_structure(files, root)
|
||||
state.findings.structure = structure
|
||||
|
||||
-- Show directory breakdown
|
||||
log("", "info")
|
||||
log(" Structure(Directories)", "progress")
|
||||
local sorted_dirs = {}
|
||||
for dir, count in pairs(structure.directories) do
|
||||
table.insert(sorted_dirs, { dir = dir, count = count })
|
||||
end
|
||||
table.sort(sorted_dirs, function(a, b)
|
||||
return a.count > b.count
|
||||
end)
|
||||
for i, entry in ipairs(sorted_dirs) do
|
||||
if i <= 5 then
|
||||
log(" ⎿ " .. entry.dir .. " (" .. entry.count .. " files)", "debug")
|
||||
end
|
||||
end
|
||||
if #sorted_dirs > 5 then
|
||||
log(" ⎿ +" .. (#sorted_dirs - 5) .. " more directories", "debug")
|
||||
end
|
||||
|
||||
-- Analyze files asynchronously
|
||||
log("", "info")
|
||||
log(" Analyze(Source files)", "progress")
|
||||
|
||||
state.files_scanned = 0
|
||||
local analyses = {}
|
||||
local key_files = {}
|
||||
|
||||
-- Process files in batches to avoid blocking
|
||||
local batch_size = 10
|
||||
local current_batch = 0
|
||||
|
||||
local function process_batch()
|
||||
local start_idx = current_batch * batch_size + 1
|
||||
local end_idx = math.min(start_idx + batch_size - 1, #files)
|
||||
|
||||
for i = start_idx, end_idx do
|
||||
local file = files[i]
|
||||
local relative = file:gsub("^" .. vim.pesc(root) .. "/", "")
|
||||
|
||||
state.files_scanned = state.files_scanned + 1
|
||||
state.current_file = relative
|
||||
|
||||
local analysis = analyze_file(file)
|
||||
if analysis then
|
||||
analysis.relative_path = relative
|
||||
table.insert(analyses, analysis)
|
||||
|
||||
-- Track key files (many functions/classes)
|
||||
if #analysis.functions >= 3 or #analysis.classes >= 1 then
|
||||
table.insert(key_files, {
|
||||
path = relative,
|
||||
functions = #analysis.functions,
|
||||
classes = #analysis.classes,
|
||||
summary = analysis.summary,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Log some files
|
||||
if i <= 3 or (i % 20 == 0) then
|
||||
log(" ⎿ " .. relative .. ": " .. (analysis and analysis.summary or "(empty)"), "file")
|
||||
end
|
||||
end
|
||||
|
||||
-- Progress update
|
||||
local progress = math.floor((state.files_scanned / state.total_files) * 100)
|
||||
if progress % 25 == 0 and progress > 0 then
|
||||
log(" ⎿ " .. progress .. "% complete (" .. state.files_scanned .. "/" .. state.total_files .. ")", "debug")
|
||||
end
|
||||
|
||||
current_batch = current_batch + 1
|
||||
|
||||
if end_idx < #files then
|
||||
-- Schedule next batch
|
||||
vim.defer_fn(process_batch, 10)
|
||||
else
|
||||
-- Complete
|
||||
finish_exploration(root, analyses, key_files, on_complete)
|
||||
end
|
||||
end
|
||||
|
||||
-- Start processing
|
||||
vim.defer_fn(process_batch, 10)
|
||||
end
|
||||
|
||||
--- Finish exploration and store results
|
||||
---@param root string
|
||||
---@param analyses table
|
||||
---@param key_files table
|
||||
---@param on_complete fun(result: table)
|
||||
function finish_exploration(root, analyses, key_files, on_complete)
|
||||
log(" ⎿ +" .. (#analyses - 3) .. " more files analyzed", "debug")
|
||||
|
||||
-- Show key files
|
||||
if #key_files > 0 then
|
||||
log("", "info")
|
||||
log(" KeyFiles(Important components)", "progress")
|
||||
table.sort(key_files, function(a, b)
|
||||
return (a.functions + a.classes * 2) > (b.functions + b.classes * 2)
|
||||
end)
|
||||
for i, kf in ipairs(key_files) do
|
||||
if i <= 5 then
|
||||
log(" ⎿ " .. kf.path .. ": " .. kf.summary, "file")
|
||||
end
|
||||
end
|
||||
if #key_files > 5 then
|
||||
log(" ⎿ +" .. (#key_files - 5) .. " more key files", "debug")
|
||||
end
|
||||
end
|
||||
|
||||
state.findings.analyses = analyses
|
||||
state.findings.key_files = key_files
|
||||
|
||||
-- Store in brain if available
|
||||
local ok_brain, brain = pcall(require, "codetyper.brain")
|
||||
if ok_brain and brain.is_initialized() then
|
||||
log("", "info")
|
||||
log(" Store(Brain context)", "progress")
|
||||
|
||||
-- Store project pattern
|
||||
brain.learn({
|
||||
type = "pattern",
|
||||
file = root,
|
||||
content = {
|
||||
summary = "Project: " .. state.findings.project.name,
|
||||
detail = state.findings.project.language
|
||||
.. " "
|
||||
.. (state.findings.project.framework or state.findings.project.type),
|
||||
code = nil,
|
||||
},
|
||||
context = {
|
||||
file = root,
|
||||
language = state.findings.project.language,
|
||||
},
|
||||
})
|
||||
|
||||
-- Store key file patterns
|
||||
for i, kf in ipairs(key_files) do
|
||||
if i <= 10 then
|
||||
brain.learn({
|
||||
type = "pattern",
|
||||
file = root .. "/" .. kf.path,
|
||||
content = {
|
||||
summary = kf.path .. " - " .. kf.summary,
|
||||
detail = kf.summary,
|
||||
},
|
||||
context = {
|
||||
file = kf.path,
|
||||
},
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
log(" ⎿ Stored " .. math.min(#key_files, 10) + 1 .. " patterns in brain", "debug")
|
||||
end
|
||||
|
||||
-- Store in indexer if available
|
||||
local ok_indexer, indexer = pcall(require, "codetyper.indexer")
|
||||
if ok_indexer then
|
||||
log(" Index(Project index)", "progress")
|
||||
indexer.index_project(function(index)
|
||||
log(" ⎿ Indexed " .. (index.stats.files or 0) .. " files", "debug")
|
||||
end)
|
||||
end
|
||||
|
||||
log("", "info")
|
||||
log("✓ Exploration complete!", "info")
|
||||
log("", "info")
|
||||
|
||||
-- Build result
|
||||
local result = {
|
||||
project = state.findings.project,
|
||||
structure = state.findings.structure,
|
||||
key_files = key_files,
|
||||
total_files = state.total_files,
|
||||
analyses = analyses,
|
||||
}
|
||||
|
||||
state.is_exploring = false
|
||||
state.on_log = nil
|
||||
|
||||
on_complete(result)
|
||||
end
|
||||
|
||||
--- Check if exploration is in progress
|
||||
---@return boolean
|
||||
function M.is_exploring()
|
||||
return state.is_exploring
|
||||
end
|
||||
|
||||
--- Get exploration progress
|
||||
---@return number scanned, number total
|
||||
function M.get_progress()
|
||||
return state.files_scanned, state.total_files
|
||||
end
|
||||
|
||||
--- Build context string from exploration result
|
||||
---@param result table Exploration result
|
||||
---@return string context
|
||||
function M.build_context(result)
|
||||
local parts = {}
|
||||
|
||||
-- Project info
|
||||
table.insert(parts, "## Project: " .. result.project.name)
|
||||
table.insert(parts, "- Type: " .. result.project.type)
|
||||
table.insert(parts, "- Language: " .. (result.project.language or "Unknown"))
|
||||
if result.project.framework then
|
||||
table.insert(parts, "- Framework: " .. result.project.framework)
|
||||
end
|
||||
table.insert(parts, "- Files: " .. result.total_files)
|
||||
table.insert(parts, "")
|
||||
|
||||
-- Structure
|
||||
table.insert(parts, "## Structure")
|
||||
if result.structure and result.structure.by_extension then
|
||||
for ext, count in pairs(result.structure.by_extension) do
|
||||
table.insert(parts, "- ." .. ext .. ": " .. count .. " files")
|
||||
end
|
||||
end
|
||||
table.insert(parts, "")
|
||||
|
||||
-- Key components
|
||||
if result.key_files and #result.key_files > 0 then
|
||||
table.insert(parts, "## Key Components")
|
||||
for i, kf in ipairs(result.key_files) do
|
||||
if i <= 10 then
|
||||
table.insert(parts, "- " .. kf.path .. ": " .. kf.summary)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,302 +0,0 @@
|
||||
---@mod codetyper.ask.intent Intent detection for Ask mode
|
||||
---@brief [[
|
||||
--- Analyzes user prompts to detect intent (ask/explain vs code generation).
|
||||
--- Routes to appropriate prompt type and context sources.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
---@alias IntentType "ask"|"explain"|"generate"|"refactor"|"document"|"test"
|
||||
|
||||
---@class Intent
|
||||
---@field type IntentType Detected intent type
|
||||
---@field confidence number 0-1 confidence score
|
||||
---@field needs_project_context boolean Whether project-wide context is needed
|
||||
---@field needs_brain_context boolean Whether brain/learned context is helpful
|
||||
---@field needs_exploration boolean Whether full project exploration is needed
|
||||
---@field keywords string[] Keywords that influenced detection
|
||||
|
||||
--- Patterns for detecting ask/explain intent (questions about code)
|
||||
local ASK_PATTERNS = {
|
||||
-- Question words
|
||||
{ pattern = "^what%s", weight = 0.9 },
|
||||
{ pattern = "^why%s", weight = 0.95 },
|
||||
{ pattern = "^how%s+does", weight = 0.9 },
|
||||
{ pattern = "^how%s+do%s+i", weight = 0.7 }, -- Could be asking for code
|
||||
{ pattern = "^where%s", weight = 0.85 },
|
||||
{ pattern = "^when%s", weight = 0.85 },
|
||||
{ pattern = "^which%s", weight = 0.8 },
|
||||
{ pattern = "^who%s", weight = 0.85 },
|
||||
{ pattern = "^can%s+you%s+explain", weight = 0.95 },
|
||||
{ pattern = "^could%s+you%s+explain", weight = 0.95 },
|
||||
{ pattern = "^please%s+explain", weight = 0.95 },
|
||||
|
||||
-- Explanation requests
|
||||
{ pattern = "explain%s", weight = 0.9 },
|
||||
{ pattern = "describe%s", weight = 0.85 },
|
||||
{ pattern = "tell%s+me%s+about", weight = 0.85 },
|
||||
{ pattern = "walk%s+me%s+through", weight = 0.9 },
|
||||
{ pattern = "help%s+me%s+understand", weight = 0.95 },
|
||||
{ pattern = "what%s+is%s+the%s+purpose", weight = 0.95 },
|
||||
{ pattern = "what%s+does%s+this", weight = 0.9 },
|
||||
{ pattern = "what%s+does%s+it", weight = 0.9 },
|
||||
{ pattern = "how%s+does%s+this%s+work", weight = 0.95 },
|
||||
{ pattern = "how%s+does%s+it%s+work", weight = 0.95 },
|
||||
|
||||
-- Understanding queries
|
||||
{ pattern = "understand", weight = 0.7 },
|
||||
{ pattern = "meaning%s+of", weight = 0.85 },
|
||||
{ pattern = "difference%s+between", weight = 0.9 },
|
||||
{ pattern = "compared%s+to", weight = 0.8 },
|
||||
{ pattern = "vs%s", weight = 0.7 },
|
||||
{ pattern = "versus", weight = 0.7 },
|
||||
{ pattern = "pros%s+and%s+cons", weight = 0.9 },
|
||||
{ pattern = "advantages", weight = 0.8 },
|
||||
{ pattern = "disadvantages", weight = 0.8 },
|
||||
{ pattern = "trade%-?offs?", weight = 0.85 },
|
||||
|
||||
-- Analysis requests
|
||||
{ pattern = "analyze", weight = 0.85 },
|
||||
{ pattern = "review", weight = 0.7 }, -- Could also be refactor
|
||||
{ pattern = "overview", weight = 0.9 },
|
||||
{ pattern = "summary", weight = 0.9 },
|
||||
{ pattern = "summarize", weight = 0.9 },
|
||||
|
||||
-- Question marks (weaker signal)
|
||||
{ pattern = "%?$", weight = 0.3 },
|
||||
{ pattern = "%?%s*$", weight = 0.3 },
|
||||
}
|
||||
|
||||
--- Patterns for detecting code generation intent
|
||||
local GENERATE_PATTERNS = {
|
||||
-- Direct commands
|
||||
{ pattern = "^create%s", weight = 0.9 },
|
||||
{ pattern = "^make%s", weight = 0.85 },
|
||||
{ pattern = "^build%s", weight = 0.85 },
|
||||
{ pattern = "^write%s", weight = 0.9 },
|
||||
{ pattern = "^add%s", weight = 0.85 },
|
||||
{ pattern = "^implement%s", weight = 0.95 },
|
||||
{ pattern = "^generate%s", weight = 0.95 },
|
||||
{ pattern = "^code%s", weight = 0.8 },
|
||||
|
||||
-- Modification commands
|
||||
{ pattern = "^fix%s", weight = 0.9 },
|
||||
{ pattern = "^change%s", weight = 0.8 },
|
||||
{ pattern = "^update%s", weight = 0.75 },
|
||||
{ pattern = "^modify%s", weight = 0.8 },
|
||||
{ pattern = "^replace%s", weight = 0.85 },
|
||||
{ pattern = "^remove%s", weight = 0.85 },
|
||||
{ pattern = "^delete%s", weight = 0.85 },
|
||||
|
||||
-- Feature requests
|
||||
{ pattern = "i%s+need%s+a", weight = 0.8 },
|
||||
{ pattern = "i%s+want%s+a", weight = 0.8 },
|
||||
{ pattern = "give%s+me", weight = 0.7 },
|
||||
{ pattern = "show%s+me%s+how%s+to%s+code", weight = 0.9 },
|
||||
{ pattern = "how%s+do%s+i%s+implement", weight = 0.85 },
|
||||
{ pattern = "can%s+you%s+write", weight = 0.9 },
|
||||
{ pattern = "can%s+you%s+create", weight = 0.9 },
|
||||
{ pattern = "can%s+you%s+add", weight = 0.85 },
|
||||
{ pattern = "can%s+you%s+make", weight = 0.85 },
|
||||
|
||||
-- Code-specific terms
|
||||
{ pattern = "function%s+that", weight = 0.85 },
|
||||
{ pattern = "class%s+that", weight = 0.85 },
|
||||
{ pattern = "method%s+that", weight = 0.85 },
|
||||
{ pattern = "component%s+that", weight = 0.85 },
|
||||
{ pattern = "module%s+that", weight = 0.85 },
|
||||
{ pattern = "api%s+for", weight = 0.8 },
|
||||
{ pattern = "endpoint%s+for", weight = 0.8 },
|
||||
}
|
||||
|
||||
--- Patterns for detecting refactor intent
|
||||
local REFACTOR_PATTERNS = {
|
||||
{ pattern = "^refactor%s", weight = 0.95 },
|
||||
{ pattern = "refactor%s+this", weight = 0.95 },
|
||||
{ pattern = "clean%s+up", weight = 0.85 },
|
||||
{ pattern = "improve%s+this%s+code", weight = 0.85 },
|
||||
{ pattern = "make%s+this%s+cleaner", weight = 0.85 },
|
||||
{ pattern = "simplify", weight = 0.8 },
|
||||
{ pattern = "optimize", weight = 0.75 }, -- Could be explain
|
||||
{ pattern = "reorganize", weight = 0.9 },
|
||||
{ pattern = "restructure", weight = 0.9 },
|
||||
{ pattern = "extract%s+to", weight = 0.9 },
|
||||
{ pattern = "split%s+into", weight = 0.85 },
|
||||
{ pattern = "dry%s+this", weight = 0.9 }, -- Don't repeat yourself
|
||||
{ pattern = "reduce%s+duplication", weight = 0.9 },
|
||||
}
|
||||
|
||||
--- Patterns for detecting documentation intent
|
||||
local DOCUMENT_PATTERNS = {
|
||||
{ pattern = "^document%s", weight = 0.95 },
|
||||
{ pattern = "add%s+documentation", weight = 0.95 },
|
||||
{ pattern = "add%s+docs", weight = 0.95 },
|
||||
{ pattern = "add%s+comments", weight = 0.9 },
|
||||
{ pattern = "add%s+docstring", weight = 0.95 },
|
||||
{ pattern = "add%s+jsdoc", weight = 0.95 },
|
||||
{ pattern = "write%s+documentation", weight = 0.95 },
|
||||
{ pattern = "document%s+this", weight = 0.95 },
|
||||
}
|
||||
|
||||
--- Patterns for detecting test generation intent
|
||||
local TEST_PATTERNS = {
|
||||
{ pattern = "^test%s", weight = 0.9 },
|
||||
{ pattern = "write%s+tests?%s+for", weight = 0.95 },
|
||||
{ pattern = "add%s+tests?%s+for", weight = 0.95 },
|
||||
{ pattern = "create%s+tests?%s+for", weight = 0.95 },
|
||||
{ pattern = "generate%s+tests?", weight = 0.95 },
|
||||
{ pattern = "unit%s+tests?", weight = 0.9 },
|
||||
{ pattern = "test%s+cases?%s+for", weight = 0.95 },
|
||||
{ pattern = "spec%s+for", weight = 0.85 },
|
||||
}
|
||||
|
||||
--- Patterns indicating project-wide context is needed
|
||||
local PROJECT_CONTEXT_PATTERNS = {
|
||||
{ pattern = "project", weight = 0.9 },
|
||||
{ pattern = "codebase", weight = 0.95 },
|
||||
{ pattern = "entire", weight = 0.7 },
|
||||
{ pattern = "whole", weight = 0.7 },
|
||||
{ pattern = "all%s+files", weight = 0.9 },
|
||||
{ pattern = "architecture", weight = 0.95 },
|
||||
{ pattern = "structure", weight = 0.85 },
|
||||
{ pattern = "how%s+is%s+.*%s+organized", weight = 0.95 },
|
||||
{ pattern = "where%s+is%s+.*%s+defined", weight = 0.9 },
|
||||
{ pattern = "dependencies", weight = 0.85 },
|
||||
{ pattern = "imports?%s+from", weight = 0.7 },
|
||||
{ pattern = "modules?", weight = 0.6 },
|
||||
{ pattern = "packages?", weight = 0.6 },
|
||||
}
|
||||
|
||||
--- Patterns indicating project exploration is needed (full indexing)
|
||||
local EXPLORE_PATTERNS = {
|
||||
{ pattern = "explain%s+.*%s*project", weight = 1.0 },
|
||||
{ pattern = "explain%s+.*%s*codebase", weight = 1.0 },
|
||||
{ pattern = "explain%s+me%s+the%s+project", weight = 1.0 },
|
||||
{ pattern = "tell%s+me%s+about%s+.*%s*project", weight = 0.95 },
|
||||
{ pattern = "what%s+is%s+this%s+project", weight = 0.95 },
|
||||
{ pattern = "overview%s+of%s+.*%s*project", weight = 0.95 },
|
||||
{ pattern = "understand%s+.*%s*project", weight = 0.9 },
|
||||
{ pattern = "analyze%s+.*%s*project", weight = 0.9 },
|
||||
{ pattern = "explore%s+.*%s*project", weight = 1.0 },
|
||||
{ pattern = "explore%s+.*%s*codebase", weight = 1.0 },
|
||||
{ pattern = "index%s+.*%s*project", weight = 1.0 },
|
||||
{ pattern = "scan%s+.*%s*project", weight = 0.95 },
|
||||
}
|
||||
|
||||
--- Match patterns against text
|
||||
---@param text string Lowercased text to match
|
||||
---@param patterns table Pattern list with weights
|
||||
---@return number Score, string[] Matched keywords
|
||||
local function match_patterns(text, patterns)
|
||||
local score = 0
|
||||
local matched = {}
|
||||
|
||||
for _, p in ipairs(patterns) do
|
||||
if text:match(p.pattern) then
|
||||
score = score + p.weight
|
||||
table.insert(matched, p.pattern)
|
||||
end
|
||||
end
|
||||
|
||||
return score, matched
|
||||
end
|
||||
|
||||
--- Detect intent from user prompt
|
||||
---@param prompt string User's question/request
|
||||
---@return Intent Detected intent
|
||||
function M.detect(prompt)
|
||||
local text = prompt:lower()
|
||||
|
||||
-- Calculate raw scores for each intent type (sum of matched weights)
|
||||
local ask_score, ask_kw = match_patterns(text, ASK_PATTERNS)
|
||||
local gen_score, gen_kw = match_patterns(text, GENERATE_PATTERNS)
|
||||
local ref_score, ref_kw = match_patterns(text, REFACTOR_PATTERNS)
|
||||
local doc_score, doc_kw = match_patterns(text, DOCUMENT_PATTERNS)
|
||||
local test_score, test_kw = match_patterns(text, TEST_PATTERNS)
|
||||
local proj_score, _ = match_patterns(text, PROJECT_CONTEXT_PATTERNS)
|
||||
local explore_score, _ = match_patterns(text, EXPLORE_PATTERNS)
|
||||
|
||||
-- Find the winner by raw score (highest accumulated weight)
|
||||
local scores = {
|
||||
{ type = "ask", score = ask_score, keywords = ask_kw },
|
||||
{ type = "generate", score = gen_score, keywords = gen_kw },
|
||||
{ type = "refactor", score = ref_score, keywords = ref_kw },
|
||||
{ type = "document", score = doc_score, keywords = doc_kw },
|
||||
{ type = "test", score = test_score, keywords = test_kw },
|
||||
}
|
||||
|
||||
table.sort(scores, function(a, b)
|
||||
return a.score > b.score
|
||||
end)
|
||||
|
||||
local winner = scores[1]
|
||||
|
||||
-- If top score is very low, default to ask (safer for Q&A)
|
||||
if winner.score < 0.3 then
|
||||
winner = { type = "ask", score = 0.5, keywords = {} }
|
||||
end
|
||||
|
||||
-- If ask and generate are close AND there's a question mark, prefer ask
|
||||
if winner.type == "generate" and ask_score > 0 then
|
||||
if text:match("%?%s*$") and ask_score >= gen_score * 0.5 then
|
||||
winner = { type = "ask", score = ask_score, keywords = ask_kw }
|
||||
end
|
||||
end
|
||||
|
||||
-- Determine if "explain" vs "ask" (explain needs more context)
|
||||
local intent_type = winner.type
|
||||
if intent_type == "ask" then
|
||||
-- "explain" if asking about how something works, otherwise "ask"
|
||||
if text:match("explain") or text:match("how%s+does") or text:match("walk%s+me%s+through") then
|
||||
intent_type = "explain"
|
||||
end
|
||||
end
|
||||
|
||||
-- Normalize confidence to 0-1 range (cap at reasonable max)
|
||||
local confidence = math.min(winner.score / 2, 1.0)
|
||||
|
||||
-- Check if exploration is needed (full project indexing)
|
||||
local needs_exploration = explore_score >= 0.9
|
||||
|
||||
---@type Intent
|
||||
local intent = {
|
||||
type = intent_type,
|
||||
confidence = confidence,
|
||||
needs_project_context = proj_score > 0.5 or needs_exploration,
|
||||
needs_brain_context = intent_type == "ask" or intent_type == "explain",
|
||||
needs_exploration = needs_exploration,
|
||||
keywords = winner.keywords,
|
||||
}
|
||||
|
||||
return intent
|
||||
end
|
||||
|
||||
--- Get prompt type for system prompt selection
|
||||
---@param intent Intent Detected intent
|
||||
---@return string Prompt type for prompts.system
|
||||
function M.get_prompt_type(intent)
|
||||
local mapping = {
|
||||
ask = "ask",
|
||||
explain = "ask", -- Uses same prompt as ask
|
||||
generate = "code_generation",
|
||||
refactor = "refactor",
|
||||
document = "document",
|
||||
test = "test",
|
||||
}
|
||||
return mapping[intent.type] or "ask"
|
||||
end
|
||||
|
||||
--- Check if intent requires code output
|
||||
---@param intent Intent
|
||||
---@return boolean
|
||||
function M.produces_code(intent)
|
||||
local code_intents = {
|
||||
generate = true,
|
||||
refactor = true,
|
||||
document = true, -- Documentation is code (comments)
|
||||
test = true,
|
||||
}
|
||||
return code_intents[intent.type] or false
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,456 +0,0 @@
|
||||
---@mod codetyper.agent.inject Smart code injection with import handling
|
||||
---@brief [[
|
||||
--- Intelligent code injection that properly handles imports, merging them
|
||||
--- into existing import sections instead of blindly appending.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ImportConfig
|
||||
---@field pattern string Lua pattern to match import statements
|
||||
---@field multi_line boolean Whether imports can span multiple lines
|
||||
---@field sort_key function|nil Function to extract sort key from import
|
||||
---@field group_by function|nil Function to group imports
|
||||
|
||||
---@class ParsedCode
|
||||
---@field imports string[] Import statements
|
||||
---@field body string[] Non-import code lines
|
||||
---@field import_lines table<number, boolean> Map of line numbers that are imports
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
local languages = require("codetyper.params.agents.languages")
|
||||
local import_patterns = languages.import_patterns
|
||||
|
||||
--- Check if a line is an import statement for the given language
|
||||
---@param line string
|
||||
---@param patterns table[] Import patterns for the language
|
||||
---@return boolean is_import
|
||||
---@return boolean is_multi_line
|
||||
local function is_import_line(line, patterns)
|
||||
for _, p in ipairs(patterns) do
|
||||
if line:match(p.pattern) then
|
||||
return true, p.multi_line or false
|
||||
end
|
||||
end
|
||||
return false, false
|
||||
end
|
||||
|
||||
|
||||
--- Check if a line ends a multi-line import
|
||||
---@param line string
|
||||
---@param filetype string
|
||||
---@return boolean
|
||||
local function ends_multiline_import(line, filetype)
|
||||
return utils.ends_multiline_import(line, filetype)
|
||||
end
|
||||
|
||||
--- Parse code into imports and body
|
||||
---@param code string|string[] Code to parse
|
||||
---@param filetype string File type/extension
|
||||
---@return ParsedCode
|
||||
function M.parse_code(code, filetype)
|
||||
local lines
|
||||
if type(code) == "string" then
|
||||
lines = vim.split(code, "\n", { plain = true })
|
||||
else
|
||||
lines = code
|
||||
end
|
||||
|
||||
local patterns = import_patterns[filetype] or import_patterns.javascript
|
||||
|
||||
local result = {
|
||||
imports = {},
|
||||
body = {},
|
||||
import_lines = {},
|
||||
}
|
||||
|
||||
local in_multiline_import = false
|
||||
local current_import_lines = {}
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
if in_multiline_import then
|
||||
-- Continue collecting multi-line import
|
||||
table.insert(current_import_lines, line)
|
||||
|
||||
if ends_multiline_import(line, filetype) then
|
||||
-- Complete the multi-line import
|
||||
table.insert(result.imports, table.concat(current_import_lines, "\n"))
|
||||
for j = i - #current_import_lines + 1, i do
|
||||
result.import_lines[j] = true
|
||||
end
|
||||
current_import_lines = {}
|
||||
in_multiline_import = false
|
||||
end
|
||||
else
|
||||
local is_import, is_multi = is_import_line(line, patterns)
|
||||
|
||||
if is_import then
|
||||
result.import_lines[i] = true
|
||||
|
||||
if is_multi and not ends_multiline_import(line, filetype) then
|
||||
-- Start of multi-line import
|
||||
in_multiline_import = true
|
||||
current_import_lines = { line }
|
||||
else
|
||||
-- Single-line import
|
||||
table.insert(result.imports, line)
|
||||
end
|
||||
else
|
||||
-- Non-import line
|
||||
table.insert(result.body, line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle unclosed multi-line import (shouldn't happen with well-formed code)
|
||||
if #current_import_lines > 0 then
|
||||
table.insert(result.imports, table.concat(current_import_lines, "\n"))
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Find the import section range in a buffer
|
||||
---@param bufnr number Buffer number
|
||||
---@param filetype string
|
||||
---@return number|nil start_line First import line (1-indexed)
|
||||
---@return number|nil end_line Last import line (1-indexed)
|
||||
function M.find_import_section(bufnr, filetype)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local patterns = import_patterns[filetype] or import_patterns.javascript
|
||||
|
||||
local first_import = nil
|
||||
local last_import = nil
|
||||
local in_multiline = false
|
||||
local consecutive_non_import = 0
|
||||
local max_gap = 3 -- Allow up to 3 blank/comment lines between imports
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
if in_multiline then
|
||||
last_import = i
|
||||
consecutive_non_import = 0
|
||||
|
||||
if ends_multiline_import(line, filetype) then
|
||||
in_multiline = false
|
||||
end
|
||||
else
|
||||
local is_import, is_multi = is_import_line(line, patterns)
|
||||
|
||||
if is_import then
|
||||
if not first_import then
|
||||
first_import = i
|
||||
end
|
||||
last_import = i
|
||||
consecutive_non_import = 0
|
||||
|
||||
if is_multi and not ends_multiline_import(line, filetype) then
|
||||
in_multiline = true
|
||||
end
|
||||
elseif utils.is_empty_or_comment(line, filetype) then
|
||||
-- Allow gaps in import section
|
||||
if first_import then
|
||||
consecutive_non_import = consecutive_non_import + 1
|
||||
if consecutive_non_import > max_gap then
|
||||
-- Too many non-import lines, import section has ended
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Non-import, non-empty line
|
||||
if first_import then
|
||||
-- Import section has ended
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return first_import, last_import
|
||||
end
|
||||
|
||||
--- Get existing imports from a buffer
|
||||
---@param bufnr number Buffer number
|
||||
---@param filetype string
|
||||
---@return string[] Existing import statements
|
||||
function M.get_existing_imports(bufnr, filetype)
|
||||
local start_line, end_line = M.find_import_section(bufnr, filetype)
|
||||
if not start_line then
|
||||
return {}
|
||||
end
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
|
||||
local parsed = M.parse_code(lines, filetype)
|
||||
return parsed.imports
|
||||
end
|
||||
|
||||
--- Normalize an import for comparison (remove whitespace variations)
|
||||
---@param import_str string
|
||||
---@return string
|
||||
local function normalize_import(import_str)
|
||||
-- Remove trailing semicolon for comparison
|
||||
local normalized = import_str:gsub(";%s*$", "")
|
||||
-- Remove all whitespace around braces, commas, colons
|
||||
normalized = normalized:gsub("%s*{%s*", "{")
|
||||
normalized = normalized:gsub("%s*}%s*", "}")
|
||||
normalized = normalized:gsub("%s*,%s*", ",")
|
||||
normalized = normalized:gsub("%s*:%s*", ":")
|
||||
-- Collapse multiple whitespace to single space
|
||||
normalized = normalized:gsub("%s+", " ")
|
||||
-- Trim leading/trailing whitespace
|
||||
normalized = normalized:match("^%s*(.-)%s*$")
|
||||
return normalized
|
||||
end
|
||||
|
||||
--- Check if two imports are duplicates
|
||||
---@param import1 string
|
||||
---@param import2 string
|
||||
---@return boolean
|
||||
local function are_duplicate_imports(import1, import2)
|
||||
return normalize_import(import1) == normalize_import(import2)
|
||||
end
|
||||
|
||||
--- Merge new imports with existing ones, avoiding duplicates
|
||||
---@param existing string[] Existing imports
|
||||
---@param new_imports string[] New imports to merge
|
||||
---@return string[] Merged imports
|
||||
function M.merge_imports(existing, new_imports)
|
||||
local merged = {}
|
||||
local seen = {}
|
||||
|
||||
-- Add existing imports
|
||||
for _, imp in ipairs(existing) do
|
||||
local normalized = normalize_import(imp)
|
||||
if not seen[normalized] then
|
||||
seen[normalized] = true
|
||||
table.insert(merged, imp)
|
||||
end
|
||||
end
|
||||
|
||||
-- Add new imports that aren't duplicates
|
||||
for _, imp in ipairs(new_imports) do
|
||||
local normalized = normalize_import(imp)
|
||||
if not seen[normalized] then
|
||||
seen[normalized] = true
|
||||
table.insert(merged, imp)
|
||||
end
|
||||
end
|
||||
|
||||
return merged
|
||||
end
|
||||
|
||||
--- Sort imports by their source/module
|
||||
---@param imports string[]
|
||||
---@param filetype string
|
||||
---@return string[]
|
||||
function M.sort_imports(imports, filetype)
|
||||
-- Group imports: stdlib/builtin first, then third-party, then local
|
||||
local builtin = {}
|
||||
local third_party = {}
|
||||
local local_imports = {}
|
||||
|
||||
for _, imp in ipairs(imports) do
|
||||
local category = utils.classify_import(imp, filetype)
|
||||
|
||||
if category == "builtin" then
|
||||
table.insert(builtin, imp)
|
||||
elseif category == "local" then
|
||||
table.insert(local_imports, imp)
|
||||
else
|
||||
table.insert(third_party, imp)
|
||||
end
|
||||
end
|
||||
|
||||
-- Sort each group alphabetically
|
||||
table.sort(builtin)
|
||||
table.sort(third_party)
|
||||
table.sort(local_imports)
|
||||
|
||||
-- Combine with proper spacing
|
||||
local result = {}
|
||||
|
||||
for _, imp in ipairs(builtin) do
|
||||
table.insert(result, imp)
|
||||
end
|
||||
if #builtin > 0 and (#third_party > 0 or #local_imports > 0) then
|
||||
table.insert(result, "") -- Blank line between groups
|
||||
end
|
||||
|
||||
for _, imp in ipairs(third_party) do
|
||||
table.insert(result, imp)
|
||||
end
|
||||
if #third_party > 0 and #local_imports > 0 then
|
||||
table.insert(result, "") -- Blank line between groups
|
||||
end
|
||||
|
||||
for _, imp in ipairs(local_imports) do
|
||||
table.insert(result, imp)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
---@class InjectResult
|
||||
---@field success boolean
|
||||
---@field imports_added number Number of new imports added
|
||||
---@field imports_merged boolean Whether imports were merged into existing section
|
||||
---@field body_lines number Number of body lines injected
|
||||
|
||||
--- Smart inject code into a buffer, properly handling imports
|
||||
---@param bufnr number Target buffer
|
||||
---@param code string|string[] Code to inject
|
||||
---@param opts table Options: { strategy: "append"|"replace"|"insert", range: {start_line, end_line}|nil, filetype: string|nil, sort_imports: boolean|nil }
|
||||
---@return InjectResult
|
||||
function M.inject(bufnr, code, opts)
|
||||
opts = opts or {}
|
||||
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return { success = false, imports_added = 0, imports_merged = false, body_lines = 0 }
|
||||
end
|
||||
|
||||
-- Get filetype
|
||||
local filetype = opts.filetype
|
||||
if not filetype then
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
filetype = vim.fn.fnamemodify(bufname, ":e")
|
||||
end
|
||||
|
||||
-- Parse the code to separate imports from body
|
||||
local parsed = M.parse_code(code, filetype)
|
||||
|
||||
local result = {
|
||||
success = true,
|
||||
imports_added = 0,
|
||||
imports_merged = false,
|
||||
body_lines = #parsed.body,
|
||||
}
|
||||
|
||||
-- Handle imports first if there are any
|
||||
if #parsed.imports > 0 then
|
||||
local import_start, import_end = M.find_import_section(bufnr, filetype)
|
||||
|
||||
if import_start then
|
||||
-- Merge with existing import section
|
||||
local existing_imports = M.get_existing_imports(bufnr, filetype)
|
||||
local merged = M.merge_imports(existing_imports, parsed.imports)
|
||||
|
||||
-- Count how many new imports were actually added
|
||||
result.imports_added = #merged - #existing_imports
|
||||
result.imports_merged = true
|
||||
|
||||
-- Optionally sort imports
|
||||
if opts.sort_imports ~= false then
|
||||
merged = M.sort_imports(merged, filetype)
|
||||
end
|
||||
|
||||
-- Convert back to lines (handling multi-line imports)
|
||||
local import_lines = {}
|
||||
for _, imp in ipairs(merged) do
|
||||
for _, line in ipairs(vim.split(imp, "\n", { plain = true })) do
|
||||
table.insert(import_lines, line)
|
||||
end
|
||||
end
|
||||
|
||||
-- Replace the import section
|
||||
vim.api.nvim_buf_set_lines(bufnr, import_start - 1, import_end, false, import_lines)
|
||||
|
||||
-- Adjust line numbers for body injection
|
||||
local lines_diff = #import_lines - (import_end - import_start + 1)
|
||||
if opts.range and opts.range.start_line and opts.range.start_line > import_end then
|
||||
opts.range.start_line = opts.range.start_line + lines_diff
|
||||
if opts.range.end_line then
|
||||
opts.range.end_line = opts.range.end_line + lines_diff
|
||||
end
|
||||
end
|
||||
else
|
||||
-- No existing import section, add imports at the top
|
||||
-- Find the first non-comment, non-empty line
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local insert_at = 0
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
-- Skip shebang, docstrings, and initial comments
|
||||
if trimmed ~= "" and not trimmed:match("^#!")
|
||||
and not trimmed:match("^['\"]") and not utils.is_empty_or_comment(line, filetype) then
|
||||
insert_at = i - 1
|
||||
break
|
||||
end
|
||||
insert_at = i
|
||||
end
|
||||
|
||||
-- Add imports with a trailing blank line
|
||||
local import_lines = {}
|
||||
for _, imp in ipairs(parsed.imports) do
|
||||
for _, line in ipairs(vim.split(imp, "\n", { plain = true })) do
|
||||
table.insert(import_lines, line)
|
||||
end
|
||||
end
|
||||
table.insert(import_lines, "") -- Blank line after imports
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, insert_at, insert_at, false, import_lines)
|
||||
result.imports_added = #parsed.imports
|
||||
result.imports_merged = false
|
||||
|
||||
-- Adjust body injection range
|
||||
if opts.range and opts.range.start_line then
|
||||
opts.range.start_line = opts.range.start_line + #import_lines
|
||||
if opts.range.end_line then
|
||||
opts.range.end_line = opts.range.end_line + #import_lines
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle body (non-import) code
|
||||
if #parsed.body > 0 then
|
||||
-- Filter out empty leading/trailing lines from body
|
||||
local body_lines = parsed.body
|
||||
while #body_lines > 0 and body_lines[1]:match("^%s*$") do
|
||||
table.remove(body_lines, 1)
|
||||
end
|
||||
while #body_lines > 0 and body_lines[#body_lines]:match("^%s*$") do
|
||||
table.remove(body_lines)
|
||||
end
|
||||
|
||||
if #body_lines > 0 then
|
||||
local line_count = vim.api.nvim_buf_line_count(bufnr)
|
||||
local strategy = opts.strategy or "append"
|
||||
|
||||
if strategy == "replace" and opts.range then
|
||||
local start_line = math.max(1, opts.range.start_line)
|
||||
local end_line = math.min(line_count, opts.range.end_line)
|
||||
vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, body_lines)
|
||||
elseif strategy == "insert" and opts.range then
|
||||
local insert_line = math.max(0, math.min(line_count, opts.range.start_line - 1))
|
||||
vim.api.nvim_buf_set_lines(bufnr, insert_line, insert_line, false, body_lines)
|
||||
else
|
||||
-- Default: append
|
||||
local last_line = vim.api.nvim_buf_get_lines(bufnr, line_count - 1, line_count, false)[1] or ""
|
||||
if last_line:match("%S") then
|
||||
-- Add blank line for spacing
|
||||
table.insert(body_lines, 1, "")
|
||||
end
|
||||
vim.api.nvim_buf_set_lines(bufnr, line_count, line_count, false, body_lines)
|
||||
end
|
||||
|
||||
result.body_lines = #body_lines
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Check if code contains imports
|
||||
---@param code string|string[]
|
||||
---@param filetype string
|
||||
---@return boolean
|
||||
function M.has_imports(code, filetype)
|
||||
local parsed = M.parse_code(code, filetype)
|
||||
return #parsed.imports > 0
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -31,7 +31,6 @@ function M.setup(opts)
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
local tree = require("codetyper.support.tree")
|
||||
local completion = require("codetyper.features.completion.inline")
|
||||
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
|
||||
|
||||
-- Register commands
|
||||
commands.setup()
|
||||
@@ -42,9 +41,6 @@ function M.setup(opts)
|
||||
-- Setup file reference completion
|
||||
completion.setup()
|
||||
|
||||
-- Setup logs panel (handles VimLeavePre cleanup)
|
||||
logs_panel.setup()
|
||||
|
||||
-- Ensure .gitignore has coder files excluded
|
||||
gitignore.ensure_ignored()
|
||||
|
||||
@@ -69,23 +65,7 @@ function M.setup(opts)
|
||||
suggestion.setup(M.config.suggestion)
|
||||
end
|
||||
|
||||
-- Start the event-driven scheduler if enabled
|
||||
if M.config.scheduler and M.config.scheduler.enabled then
|
||||
local scheduler = require("codetyper.core.scheduler.scheduler")
|
||||
scheduler.start(M.config.scheduler)
|
||||
end
|
||||
|
||||
M._initialized = true
|
||||
|
||||
-- Auto-open Ask panel after a short delay (to let UI settle)
|
||||
if M.config.auto_open_ask then
|
||||
vim.defer_fn(function()
|
||||
local ask = require("codetyper.features.ask.engine")
|
||||
if not ask.is_open() then
|
||||
ask.open()
|
||||
end
|
||||
end, 300)
|
||||
end
|
||||
end
|
||||
|
||||
--- Get current configuration
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
M.params = {
|
||||
{
|
||||
name = "command",
|
||||
description = "The shell command to execute",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "cwd",
|
||||
description = "Working directory for the command (optional)",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
{
|
||||
name = "timeout",
|
||||
description = "Timeout in milliseconds (default: 120000)",
|
||||
type = "integer",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
M.returns = {
|
||||
{
|
||||
name = "stdout",
|
||||
description = "Command output",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "error",
|
||||
description = "Error message if command failed",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,40 +0,0 @@
|
||||
---@mod codetyper.params.agents.confidence Parameters for confidence scoring
|
||||
local M = {}
|
||||
|
||||
--- Heuristic weights (must sum to 1.0)
|
||||
M.weights = {
|
||||
length = 0.15, -- Response length relative to prompt
|
||||
uncertainty = 0.30, -- Uncertainty phrases
|
||||
syntax = 0.25, -- Syntax completeness
|
||||
repetition = 0.15, -- Duplicate lines
|
||||
truncation = 0.15, -- Incomplete ending
|
||||
}
|
||||
|
||||
--- Uncertainty phrases that indicate low confidence
|
||||
M.uncertainty_phrases = {
|
||||
-- English
|
||||
"i'm not sure",
|
||||
"i am not sure",
|
||||
"maybe",
|
||||
"perhaps",
|
||||
"might work",
|
||||
"could work",
|
||||
"not certain",
|
||||
"uncertain",
|
||||
"i think",
|
||||
"possibly",
|
||||
"TODO",
|
||||
"FIXME",
|
||||
"XXX",
|
||||
"placeholder",
|
||||
"implement this",
|
||||
"fill in",
|
||||
"your code here",
|
||||
"...", -- Ellipsis as placeholder
|
||||
"# TODO",
|
||||
"// TODO",
|
||||
"-- TODO",
|
||||
"/* TODO",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,33 +0,0 @@
|
||||
---@mod codetyper.params.agents.conflict Parameters for conflict resolution
|
||||
local M = {}
|
||||
|
||||
--- Configuration defaults
|
||||
M.config = {
|
||||
-- Run linter check after accepting AI suggestions
|
||||
lint_after_accept = true,
|
||||
-- Auto-fix lint errors without prompting
|
||||
auto_fix_lint_errors = true,
|
||||
-- Auto-show menu after injecting conflict
|
||||
auto_show_menu = true,
|
||||
-- Auto-show menu for next conflict after resolving one
|
||||
auto_show_next_menu = true,
|
||||
}
|
||||
|
||||
--- Highlight groups
|
||||
M.hl_groups = {
|
||||
current = "CoderConflictCurrent",
|
||||
current_label = "CoderConflictCurrentLabel",
|
||||
incoming = "CoderConflictIncoming",
|
||||
incoming_label = "CoderConflictIncomingLabel",
|
||||
separator = "CoderConflictSeparator",
|
||||
hint = "CoderConflictHint",
|
||||
}
|
||||
|
||||
--- Conflict markers
|
||||
M.markers = {
|
||||
current_start = "<<<<<<< CURRENT",
|
||||
separator = "=======",
|
||||
incoming_end = ">>>>>>> INCOMING",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,48 +0,0 @@
|
||||
---@mod codetyper.params.agents.context Parameters for context building
|
||||
local M = {}
|
||||
|
||||
--- Common ignore patterns
|
||||
M.ignore_patterns = {
|
||||
"^%.", -- Hidden files/dirs
|
||||
"node_modules",
|
||||
"%.git$",
|
||||
"__pycache__",
|
||||
"%.pyc$",
|
||||
"target", -- Rust
|
||||
"build",
|
||||
"dist",
|
||||
"%.o$",
|
||||
"%.a$",
|
||||
"%.so$",
|
||||
"%.min%.",
|
||||
"%.map$",
|
||||
}
|
||||
|
||||
--- Key files that are important for understanding the project
|
||||
M.important_files = {
|
||||
["package.json"] = "Node.js project config",
|
||||
["Cargo.toml"] = "Rust project config",
|
||||
["go.mod"] = "Go module config",
|
||||
["pyproject.toml"] = "Python project config",
|
||||
["setup.py"] = "Python setup config",
|
||||
["Makefile"] = "Build configuration",
|
||||
["CMakeLists.txt"] = "CMake config",
|
||||
[".gitignore"] = "Git ignore patterns",
|
||||
["README.md"] = "Project documentation",
|
||||
["init.lua"] = "Neovim plugin entry",
|
||||
["plugin.lua"] = "Neovim plugin config",
|
||||
}
|
||||
|
||||
--- Project type detection indicators
|
||||
M.indicators = {
|
||||
["package.json"] = { type = "node", language = "javascript/typescript" },
|
||||
["Cargo.toml"] = { type = "rust", language = "rust" },
|
||||
["go.mod"] = { type = "go", language = "go" },
|
||||
["pyproject.toml"] = { type = "python", language = "python" },
|
||||
["setup.py"] = { type = "python", language = "python" },
|
||||
["Gemfile"] = { type = "ruby", language = "ruby" },
|
||||
["pom.xml"] = { type = "maven", language = "java" },
|
||||
["build.gradle"] = { type = "gradle", language = "java/kotlin" },
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,33 +0,0 @@
|
||||
M.params = {
|
||||
{
|
||||
name = "path",
|
||||
description = "Path to the file to edit",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "old_string",
|
||||
description = "Text to find and replace (empty string to create new file or append)",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "new_string",
|
||||
description = "Text to replace with",
|
||||
type = "string",
|
||||
},
|
||||
}
|
||||
|
||||
M.returns = {
|
||||
{
|
||||
name = "success",
|
||||
description = "Whether the edit was applied",
|
||||
type = "boolean",
|
||||
},
|
||||
{
|
||||
name = "error",
|
||||
description = "Error message if edit failed",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,10 +0,0 @@
|
||||
M.description = [[Searches for a pattern in files using ripgrep.
|
||||
|
||||
Returns file paths and matching lines. Use this to find code by content.
|
||||
|
||||
Example patterns:
|
||||
- "function foo" - Find function definitions
|
||||
- "import.*react" - Find React imports
|
||||
- "TODO|FIXME" - Find todo comments]]
|
||||
|
||||
return M
|
||||
@@ -1,161 +0,0 @@
|
||||
---@mod codetyper.params.agents.intent Intent patterns and scope configuration
|
||||
local M = {}
|
||||
|
||||
--- Intent patterns with associated metadata
|
||||
M.intent_patterns = {
|
||||
-- Complete: fill in missing implementation
|
||||
complete = {
|
||||
patterns = {
|
||||
"complete",
|
||||
"finish",
|
||||
"implement",
|
||||
"fill in",
|
||||
"fill out",
|
||||
"stub",
|
||||
"todo",
|
||||
"fixme",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "replace",
|
||||
priority = 1,
|
||||
},
|
||||
|
||||
-- Refactor: rewrite existing code
|
||||
refactor = {
|
||||
patterns = {
|
||||
"refactor",
|
||||
"rewrite",
|
||||
"restructure",
|
||||
"reorganize",
|
||||
"clean up",
|
||||
"cleanup",
|
||||
"simplify",
|
||||
"improve",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "replace",
|
||||
priority = 2,
|
||||
},
|
||||
|
||||
-- Fix: repair bugs or issues
|
||||
fix = {
|
||||
patterns = {
|
||||
"fix",
|
||||
"repair",
|
||||
"correct",
|
||||
"debug",
|
||||
"solve",
|
||||
"resolve",
|
||||
"patch",
|
||||
"bug",
|
||||
"error",
|
||||
"issue",
|
||||
"update",
|
||||
"modify",
|
||||
"change",
|
||||
"adjust",
|
||||
"tweak",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "replace",
|
||||
priority = 1,
|
||||
},
|
||||
|
||||
-- Add: insert new code
|
||||
add = {
|
||||
patterns = {
|
||||
"add",
|
||||
"create",
|
||||
"insert",
|
||||
"include",
|
||||
"append",
|
||||
"new",
|
||||
"generate",
|
||||
"write",
|
||||
},
|
||||
scope_hint = nil, -- Could be anywhere
|
||||
action = "insert",
|
||||
priority = 3,
|
||||
},
|
||||
|
||||
-- Document: add documentation
|
||||
document = {
|
||||
patterns = {
|
||||
"document",
|
||||
"comment",
|
||||
"jsdoc",
|
||||
"docstring",
|
||||
"describe",
|
||||
"annotate",
|
||||
"type hint",
|
||||
"typehint",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "replace", -- Replace with documented version
|
||||
priority = 2,
|
||||
},
|
||||
|
||||
-- Test: generate tests
|
||||
test = {
|
||||
patterns = {
|
||||
"test",
|
||||
"spec",
|
||||
"unit test",
|
||||
"integration test",
|
||||
"coverage",
|
||||
},
|
||||
scope_hint = "file",
|
||||
action = "append",
|
||||
priority = 3,
|
||||
},
|
||||
|
||||
-- Optimize: improve performance
|
||||
optimize = {
|
||||
patterns = {
|
||||
"optimize",
|
||||
"performance",
|
||||
"faster",
|
||||
"efficient",
|
||||
"speed up",
|
||||
"reduce",
|
||||
"minimize",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "replace",
|
||||
priority = 2,
|
||||
},
|
||||
|
||||
-- Explain: provide explanation (no code change)
|
||||
explain = {
|
||||
patterns = {
|
||||
"explain",
|
||||
"what does",
|
||||
"how does",
|
||||
"why",
|
||||
"describe",
|
||||
"walk through",
|
||||
"understand",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "none",
|
||||
priority = 4,
|
||||
},
|
||||
}
|
||||
|
||||
--- Scope hint patterns
|
||||
M.scope_patterns = {
|
||||
["this function"] = "function",
|
||||
["this method"] = "function",
|
||||
["the function"] = "function",
|
||||
["the method"] = "function",
|
||||
["this class"] = "class",
|
||||
["the class"] = "class",
|
||||
["this file"] = "file",
|
||||
["the file"] = "file",
|
||||
["this block"] = "block",
|
||||
["the block"] = "block",
|
||||
["this"] = nil, -- Use Tree-sitter to determine
|
||||
["here"] = nil,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,87 +0,0 @@
|
||||
---@mod codetyper.params.agents.languages Language-specific patterns and configurations
|
||||
local M = {}
|
||||
|
||||
--- Language-specific import patterns
|
||||
M.import_patterns = {
|
||||
-- JavaScript/TypeScript
|
||||
javascript = {
|
||||
{ pattern = "^%s*import%s+.+%s+from%s+['\"]", multi_line = true },
|
||||
{ pattern = "^%s*import%s+['\"]", multi_line = false },
|
||||
{ pattern = "^%s*import%s*{", multi_line = true },
|
||||
{ pattern = "^%s*import%s*%*", multi_line = true },
|
||||
{ pattern = "^%s*export%s+{.+}%s+from%s+['\"]", multi_line = true },
|
||||
{ pattern = "^%s*const%s+%w+%s*=%s*require%(['\"]", multi_line = false },
|
||||
{ pattern = "^%s*let%s+%w+%s*=%s*require%(['\"]", multi_line = false },
|
||||
{ pattern = "^%s*var%s+%w+%s*=%s*require%(['\"]", multi_line = false },
|
||||
},
|
||||
-- Python
|
||||
python = {
|
||||
{ pattern = "^%s*import%s+%w", multi_line = false },
|
||||
{ pattern = "^%s*from%s+[%w%.]+%s+import%s+", multi_line = true },
|
||||
},
|
||||
-- Lua
|
||||
lua = {
|
||||
{ pattern = "^%s*local%s+%w+%s*=%s*require%s*%(?['\"]", multi_line = false },
|
||||
{ pattern = "^%s*require%s*%(?['\"]", multi_line = false },
|
||||
},
|
||||
-- Go
|
||||
go = {
|
||||
{ pattern = "^%s*import%s+%(?", multi_line = true },
|
||||
},
|
||||
-- Rust
|
||||
rust = {
|
||||
{ pattern = "^%s*use%s+", multi_line = true },
|
||||
{ pattern = "^%s*extern%s+crate%s+", multi_line = false },
|
||||
},
|
||||
-- C/C++
|
||||
c = {
|
||||
{ pattern = "^%s*#include%s*[<\"]", multi_line = false },
|
||||
},
|
||||
-- Java/Kotlin
|
||||
java = {
|
||||
{ pattern = "^%s*import%s+", multi_line = false },
|
||||
},
|
||||
-- Ruby
|
||||
ruby = {
|
||||
{ pattern = "^%s*require%s+['\"]", multi_line = false },
|
||||
{ pattern = "^%s*require_relative%s+['\"]", multi_line = false },
|
||||
},
|
||||
-- PHP
|
||||
php = {
|
||||
{ pattern = "^%s*use%s+", multi_line = false },
|
||||
{ pattern = "^%s*require%s+['\"]", multi_line = false },
|
||||
{ pattern = "^%s*require_once%s+['\"]", multi_line = false },
|
||||
{ pattern = "^%s*include%s+['\"]", multi_line = false },
|
||||
{ pattern = "^%s*include_once%s+['\"]", multi_line = false },
|
||||
},
|
||||
}
|
||||
|
||||
-- Alias common extensions to language configs
|
||||
M.import_patterns.ts = M.import_patterns.javascript
|
||||
M.import_patterns.tsx = M.import_patterns.javascript
|
||||
M.import_patterns.jsx = M.import_patterns.javascript
|
||||
M.import_patterns.mjs = M.import_patterns.javascript
|
||||
M.import_patterns.cjs = M.import_patterns.javascript
|
||||
M.import_patterns.py = M.import_patterns.python
|
||||
M.import_patterns.cpp = M.import_patterns.c
|
||||
M.import_patterns.hpp = M.import_patterns.c
|
||||
M.import_patterns.h = M.import_patterns.c
|
||||
M.import_patterns.kt = M.import_patterns.java
|
||||
M.import_patterns.rs = M.import_patterns.rust
|
||||
M.import_patterns.rb = M.import_patterns.ruby
|
||||
|
||||
--- Language-specific comment patterns
|
||||
M.comment_patterns = {
|
||||
lua = { "^%-%-" },
|
||||
python = { "^#" },
|
||||
javascript = { "^//", "^/%*", "^%*" },
|
||||
typescript = { "^//", "^/%*", "^%*" },
|
||||
go = { "^//", "^/%*", "^%*" },
|
||||
rust = { "^//", "^/%*", "^%*" },
|
||||
c = { "^//", "^/%*", "^%*", "^#" },
|
||||
java = { "^//", "^/%*", "^%*" },
|
||||
ruby = { "^#" },
|
||||
php = { "^//", "^/%*", "^%*", "^#" },
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,15 +0,0 @@
|
||||
---@mod codetyper.params.agents.linter Linter configuration
|
||||
local M = {}
|
||||
|
||||
M.config = {
|
||||
-- Auto-save file after code injection
|
||||
auto_save = true,
|
||||
-- Delay in ms to wait for LSP diagnostics to update
|
||||
diagnostic_delay_ms = 500,
|
||||
-- Severity levels to check (1=Error, 2=Warning, 3=Info, 4=Hint)
|
||||
min_severity = vim.diagnostic.severity.WARN,
|
||||
-- Auto-offer to fix lint errors
|
||||
auto_offer_fix = true,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,36 +0,0 @@
|
||||
---@mod codetyper.params.agents.logs Log parameters
|
||||
local M = {}
|
||||
|
||||
M.icons = {
|
||||
start = "->",
|
||||
success = "OK",
|
||||
error = "ERR",
|
||||
approval = "??",
|
||||
approved = "YES",
|
||||
rejected = "NO",
|
||||
}
|
||||
|
||||
M.level_icons = {
|
||||
info = "i",
|
||||
debug = ".",
|
||||
request = ">",
|
||||
response = "<",
|
||||
tool = "T",
|
||||
error = "!",
|
||||
warning = "?",
|
||||
success = "i",
|
||||
queue = "Q",
|
||||
patch = "P",
|
||||
}
|
||||
|
||||
M.thinking_types = { "thinking", "reason", "action", "task", "result" }
|
||||
|
||||
M.thinking_prefixes = {
|
||||
thinking = "⏺",
|
||||
reason = "⏺",
|
||||
action = "⏺",
|
||||
task = "✶",
|
||||
result = "",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,15 +0,0 @@
|
||||
---@mod codetyper.params.agents.parser Parser regex patterns
|
||||
local M = {}
|
||||
|
||||
M.patterns = {
|
||||
fenced_json = "```json%s*(%b{})%s*```",
|
||||
inline_json = '(%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%})',
|
||||
}
|
||||
|
||||
M.defaults = {
|
||||
stop_reason = "end_turn",
|
||||
tool_stop_reason = "tool_use",
|
||||
replacement_text = "[Tool call]",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,12 +0,0 @@
|
||||
---@mod codetyper.params.agents.patch Patch configuration
|
||||
local M = {}
|
||||
|
||||
M.config = {
|
||||
snapshot_range = 5, -- Lines above/below prompt to snapshot
|
||||
clean_interval_ms = 60000, -- Check for stale patches every minute
|
||||
max_age_ms = 3600000, -- 1 hour TTL
|
||||
staleness_check = true,
|
||||
use_search_replace_parser = true, -- Enable new parsing logic
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,47 +0,0 @@
|
||||
---@mod codetyper.params.agents.permissions Dangerous and safe command patterns
|
||||
local M = {}
|
||||
|
||||
--- Dangerous command patterns that should never be auto-allowed
|
||||
M.dangerous_patterns = {
|
||||
"^rm%s+%-rf",
|
||||
"^rm%s+%-r%s+/",
|
||||
"^rm%s+/",
|
||||
"^sudo%s+rm",
|
||||
"^chmod%s+777",
|
||||
"^chmod%s+%-R",
|
||||
"^chown%s+%-R",
|
||||
"^dd%s+",
|
||||
"^mkfs",
|
||||
"^fdisk",
|
||||
"^format",
|
||||
":.*>%s*/dev/",
|
||||
"^curl.*|.*sh",
|
||||
"^wget.*|.*sh",
|
||||
"^eval%s+",
|
||||
"`;.*`",
|
||||
"%$%(.*%)",
|
||||
"fork%s*bomb",
|
||||
}
|
||||
|
||||
--- Safe command patterns that can be auto-allowed
|
||||
M.safe_patterns = {
|
||||
"^ls%s",
|
||||
"^ls$",
|
||||
"^cat%s",
|
||||
"^head%s",
|
||||
"^tail%s",
|
||||
"^grep%s",
|
||||
"^find%s",
|
||||
"^pwd$",
|
||||
"^echo%s",
|
||||
"^wc%s",
|
||||
"^git%s+status",
|
||||
"^git%s+diff",
|
||||
"^git%s+log",
|
||||
"^git%s+show",
|
||||
"^git%s+branch",
|
||||
"^git%s+checkout",
|
||||
"^git%s+add", -- Generally safe if reviewing changes
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,14 +0,0 @@
|
||||
---@mod codetyper.params.agents.scheduler Scheduler configuration
|
||||
local M = {}
|
||||
|
||||
M.config = {
|
||||
enabled = true,
|
||||
ollama_scout = true,
|
||||
escalation_threshold = 0.7,
|
||||
max_concurrent = 2,
|
||||
completion_delay_ms = 100,
|
||||
apply_delay_ms = 5000, -- Wait before applying code
|
||||
remote_provider = "copilot", -- Default fallback provider
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,72 +0,0 @@
|
||||
---@mod codetyper.params.agents.scope Tree-sitter scope mappings
|
||||
local M = {}
|
||||
|
||||
--- Node types that represent function-like scopes per language
|
||||
M.function_nodes = {
|
||||
-- Lua
|
||||
["function_declaration"] = "function",
|
||||
["function_definition"] = "function",
|
||||
["local_function"] = "function",
|
||||
["function"] = "function",
|
||||
|
||||
-- JavaScript/TypeScript
|
||||
["function_declaration"] = "function",
|
||||
["function_expression"] = "function",
|
||||
["arrow_function"] = "function",
|
||||
["method_definition"] = "method",
|
||||
["function"] = "function",
|
||||
|
||||
-- Python
|
||||
["function_definition"] = "function",
|
||||
["lambda"] = "function",
|
||||
|
||||
-- Go
|
||||
["function_declaration"] = "function",
|
||||
["method_declaration"] = "method",
|
||||
["func_literal"] = "function",
|
||||
|
||||
-- Rust
|
||||
["function_item"] = "function",
|
||||
["closure_expression"] = "function",
|
||||
|
||||
-- C/C++
|
||||
["function_definition"] = "function",
|
||||
["lambda_expression"] = "function",
|
||||
|
||||
-- Java
|
||||
["method_declaration"] = "method",
|
||||
["constructor_declaration"] = "method",
|
||||
["lambda_expression"] = "function",
|
||||
|
||||
-- Ruby
|
||||
["method"] = "method",
|
||||
["singleton_method"] = "method",
|
||||
["lambda"] = "function",
|
||||
["block"] = "function",
|
||||
|
||||
-- PHP
|
||||
["function_definition"] = "function",
|
||||
["method_declaration"] = "method",
|
||||
["arrow_function"] = "function",
|
||||
}
|
||||
|
||||
--- Node types that represent class-like scopes
|
||||
M.class_nodes = {
|
||||
["class_declaration"] = "class",
|
||||
["class_definition"] = "class",
|
||||
["struct_declaration"] = "class",
|
||||
["impl_item"] = "class", -- Rust config
|
||||
["interface_declaration"] = "class",
|
||||
["trait_item"] = "class",
|
||||
}
|
||||
|
||||
--- Node types that represent block scopes
|
||||
M.block_nodes = {
|
||||
["block"] = "block",
|
||||
["do_statement"] = "block", -- Lua
|
||||
["if_statement"] = "block",
|
||||
["for_statement"] = "block",
|
||||
["while_statement"] = "block",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,11 +0,0 @@
|
||||
---@mod codetyper.params.agents.search_replace Search/Replace patterns
|
||||
local M = {}
|
||||
|
||||
M.patterns = {
|
||||
dash_style = "%-%-%-%-%-%-%-?%s*SEARCH%s*\n(.-)\n=======%s*\n(.-)\n%+%+%+%+%+%+%+?%s*REPLACE",
|
||||
claude_style = "<<<<<<<[%s]*SEARCH%s*\n(.-)\n=======%s*\n(.-)\n>>>>>>>[%s]*REPLACE",
|
||||
simple_style = "%[SEARCH%]%s*\n(.-)\n%[REPLACE%]%s*\n(.-)\n%[END%]",
|
||||
diff_block = "```diff\n(.-)\n```",
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,147 +0,0 @@
|
||||
---@mod codetyper.params.agents.tools Tool definitions
|
||||
local M = {}
|
||||
|
||||
--- Tool definitions in a provider-agnostic format
|
||||
M.definitions = {
|
||||
read_file = {
|
||||
name = "read_file",
|
||||
description = "Read the contents of a file at the specified path",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "The path to the file to read",
|
||||
},
|
||||
start_line = {
|
||||
type = "number",
|
||||
description = "Optional start line number (1-indexed)",
|
||||
},
|
||||
end_line = {
|
||||
type = "number",
|
||||
description = "Optional end line number (1-indexed)",
|
||||
},
|
||||
},
|
||||
required = { "path" },
|
||||
},
|
||||
},
|
||||
|
||||
edit_file = {
|
||||
name = "edit_file",
|
||||
description = "Edit a file by replacing specific content. Provide the exact content to find and the replacement.",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "The path to the file to edit",
|
||||
},
|
||||
find = {
|
||||
type = "string",
|
||||
description = "The exact content to replace",
|
||||
},
|
||||
replace = {
|
||||
type = "string",
|
||||
description = "The new content",
|
||||
},
|
||||
},
|
||||
required = { "path", "find", "replace" },
|
||||
},
|
||||
},
|
||||
|
||||
write_file = {
|
||||
name = "write_file",
|
||||
description = "Write content to a file, creating it if it doesn't exist or overwriting if it does",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "The path to the file to write",
|
||||
},
|
||||
content = {
|
||||
type = "string",
|
||||
description = "The content to write",
|
||||
},
|
||||
},
|
||||
required = { "path", "content" },
|
||||
},
|
||||
},
|
||||
|
||||
bash = {
|
||||
name = "bash",
|
||||
description = "Execute a bash command and return the output. Use for git, npm, build tools, etc.",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
command = {
|
||||
type = "string",
|
||||
description = "The bash command to execute",
|
||||
},
|
||||
},
|
||||
required = { "command" },
|
||||
},
|
||||
},
|
||||
|
||||
delete_file = {
|
||||
name = "delete_file",
|
||||
description = "Delete a file",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "The path to the file to delete",
|
||||
},
|
||||
reason = {
|
||||
type = "string",
|
||||
description = "Reason for deletion",
|
||||
},
|
||||
},
|
||||
required = { "path", "reason" },
|
||||
},
|
||||
},
|
||||
|
||||
list_directory = {
|
||||
name = "list_directory",
|
||||
description = "List files and directories in a path",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "The path to list",
|
||||
},
|
||||
recursive = {
|
||||
type = "boolean",
|
||||
description = "Whether to list recursively",
|
||||
},
|
||||
},
|
||||
required = { "path" },
|
||||
},
|
||||
},
|
||||
|
||||
search_files = {
|
||||
name = "search_files",
|
||||
description = "Search for files by name/glob pattern or content",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
pattern = {
|
||||
type = "string",
|
||||
description = "Glob pattern to search for filenames",
|
||||
},
|
||||
content = {
|
||||
type = "string",
|
||||
description = "Content string to search for within files",
|
||||
},
|
||||
path = {
|
||||
type = "string",
|
||||
description = "The root path to start search",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,37 +0,0 @@
|
||||
local M = {}
|
||||
|
||||
M.params = {
|
||||
{
|
||||
name = "path",
|
||||
description = "Path to the file (relative to project root or absolute)",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "start_line",
|
||||
description = "Line number to start reading (1-indexed)",
|
||||
type = "integer",
|
||||
optional = true,
|
||||
},
|
||||
{
|
||||
name = "end_line",
|
||||
description = "Line number to end reading (1-indexed, inclusive)",
|
||||
type = "integer",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
M.returns = {
|
||||
{
|
||||
name = "content",
|
||||
description = "File contents as JSON with content, total_line_count, is_truncated",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "error",
|
||||
description = "Error message if file could not be read",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,30 +0,0 @@
|
||||
---@mod codetyper.params.agents.worker Worker configuration and patterns
|
||||
local M = {}
|
||||
|
||||
--- Patterns that indicate LLM needs more context (must be near start of response)
|
||||
M.context_needed_patterns = {
|
||||
"I need to see",
|
||||
"Could you provide",
|
||||
"Please provide",
|
||||
"Can you show",
|
||||
"don't have enough context",
|
||||
"need more information",
|
||||
"cannot see the definition",
|
||||
"missing the implementation",
|
||||
"I would need to check",
|
||||
"please share",
|
||||
"Please upload",
|
||||
"could not find",
|
||||
}
|
||||
|
||||
--- Default timeouts by provider type
|
||||
M.default_timeouts = {
|
||||
openai = 60000, -- 60s
|
||||
anthropic = 90000, -- 90s
|
||||
google = 60000, -- 60s
|
||||
ollama = 120000, -- 120s (local models can be slower)
|
||||
copilot = 60000, -- 60s
|
||||
default = 60000,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,30 +0,0 @@
|
||||
local M = {}
|
||||
|
||||
M.params = {
|
||||
{
|
||||
name = "path",
|
||||
description = "Path to the file to write",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "content",
|
||||
description = "Content to write to the file",
|
||||
type = "string",
|
||||
},
|
||||
}
|
||||
|
||||
M.returns = {
|
||||
{
|
||||
name = "success",
|
||||
description = "Whether the file was written successfully",
|
||||
type = "boolean",
|
||||
},
|
||||
{
|
||||
name = "error",
|
||||
description = "Error message if write failed",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,16 +0,0 @@
|
||||
M.description = [[Executes a bash command in a shell.
|
||||
|
||||
IMPORTANT RULES:
|
||||
- Do NOT use bash to read files (use 'view' tool instead)
|
||||
- Do NOT use bash to modify files (use 'write' or 'edit' tools instead)
|
||||
- Do NOT use interactive commands (vim, nano, less, etc.)
|
||||
- Commands timeout after 2 minutes by default
|
||||
|
||||
Allowed uses:
|
||||
- Running builds (make, npm run build, cargo build)
|
||||
- Running tests (npm test, pytest, cargo test)
|
||||
- Git operations (git status, git diff, git commit)
|
||||
- Package management (npm install, pip install)
|
||||
- System info commands (ls, pwd, which)]]
|
||||
|
||||
return M
|
||||
@@ -1,66 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.diff Prompts and UI strings for diff view and bash approval
|
||||
local M = {}
|
||||
|
||||
--- Bash approval dialog strings
|
||||
M.bash_approval = {
|
||||
title = " BASH COMMAND APPROVAL",
|
||||
divider = " " .. string.rep("─", 56),
|
||||
command_label = " Command:",
|
||||
warning_prefix = " ⚠️ WARNING: ",
|
||||
options = {
|
||||
" [y] Allow once - Execute this command",
|
||||
" [s] Allow this session - Auto-allow until restart",
|
||||
" [a] Add to allow list - Always allow this command",
|
||||
" [n] Reject - Cancel execution",
|
||||
},
|
||||
cancel_hint = " Press key to choose | [q] or [Esc] to cancel",
|
||||
}
|
||||
|
||||
--- Diff view help message
|
||||
M.diff_help = {
|
||||
{ "Diff: ", "Normal" },
|
||||
{ "{path}", "Directory" },
|
||||
{ " | ", "Normal" },
|
||||
{ "y/<CR>", "Keyword" },
|
||||
{ " approve ", "Normal" },
|
||||
{ "n/q/<Esc>", "Keyword" },
|
||||
{ " reject ", "Normal" },
|
||||
{ "<Tab>", "Keyword" },
|
||||
{ " switch panes", "Normal" },
|
||||
}
|
||||
|
||||
|
||||
--- Review UI interface strings
|
||||
M.review = {
|
||||
diff_header = {
|
||||
top = "╭─ %s %s %s ─────────────────────────────────────",
|
||||
path = "│ %s",
|
||||
op = "│ Operation: %s",
|
||||
status = "│ Status: %s",
|
||||
bottom = "╰────────────────────────────────────────────────────",
|
||||
},
|
||||
list_menu = {
|
||||
top = "╭─ Changes (%s) ──────────╮",
|
||||
items = {
|
||||
"│ │",
|
||||
"│ j/k: navigate │",
|
||||
"│ Enter: view diff │",
|
||||
"│ a: approve r: reject │",
|
||||
"│ A: approve all │",
|
||||
"│ q: close │",
|
||||
},
|
||||
bottom = "╰──────────────────────────────╯",
|
||||
},
|
||||
status = {
|
||||
applied = "Applied",
|
||||
approved = "Approved",
|
||||
pending = "Pending",
|
||||
},
|
||||
messages = {
|
||||
no_changes = " No changes to review",
|
||||
no_changes_short = "No changes to review",
|
||||
applied_count = "Applied %d change(s)",
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,14 +0,0 @@
|
||||
M.description = [[Makes a targeted edit to a file by replacing text.
|
||||
|
||||
The old_string should match the content you want to replace. The tool uses multiple
|
||||
matching strategies with fallbacks:
|
||||
1. Exact match
|
||||
2. Whitespace-normalized match
|
||||
3. Indentation-flexible match
|
||||
4. Line-trimmed match
|
||||
5. Fuzzy anchor-based match
|
||||
|
||||
For creating new files, use old_string="" and provide the full content in new_string.
|
||||
For large changes, consider using 'write' tool instead.]]
|
||||
|
||||
return M
|
||||
@@ -1,41 +0,0 @@
|
||||
M.params = {
|
||||
{
|
||||
name = "pattern",
|
||||
description = "Regular expression pattern to search for",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "path",
|
||||
description = "Directory or file to search in (default: project root)",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
{
|
||||
name = "include",
|
||||
description = "File glob pattern to include (e.g., '*.lua')",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
{
|
||||
name = "max_results",
|
||||
description = "Maximum number of results (default: 50)",
|
||||
type = "integer",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
M.returns = {
|
||||
{
|
||||
name = "matches",
|
||||
description = "JSON array of matches with file, line_number, and content",
|
||||
type = "string",
|
||||
},
|
||||
{
|
||||
name = "error",
|
||||
description = "Error message if search failed",
|
||||
type = "string",
|
||||
optional = true,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,141 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.init Agent prompts for Codetyper.nvim
|
||||
---
|
||||
--- System prompts for the agentic mode with tool use.
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Build the system prompt with project context
|
||||
---@return string System prompt with context
|
||||
function M.build_system_prompt()
|
||||
local base = M.system
|
||||
|
||||
-- Add project context
|
||||
local ok, context_builder = pcall(require, "codetyper.agent.context_builder")
|
||||
if ok then
|
||||
local context = context_builder.build_full_context()
|
||||
if context and context ~= "" then
|
||||
base = base .. "\n\n=== PROJECT CONTEXT ===\n" .. context .. "\n=== END PROJECT CONTEXT ===\n"
|
||||
end
|
||||
end
|
||||
|
||||
return base .. "\n\n" .. M.tool_instructions
|
||||
end
|
||||
|
||||
--- System prompt for agent mode
|
||||
M.system =
|
||||
[[You are an expert AI coding assistant integrated into Neovim. You MUST use the provided tools to accomplish tasks.
|
||||
|
||||
## CRITICAL: YOU MUST USE TOOLS
|
||||
|
||||
**NEVER output code in your response text.** Instead, you MUST call the write_file tool to create files.
|
||||
|
||||
WRONG (do NOT do this):
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
|
||||
RIGHT (do this instead):
|
||||
Call the write_file tool with path="hello.py" and content="print(\"hello\")\n"
|
||||
|
||||
## AVAILABLE TOOLS
|
||||
|
||||
### File Operations
|
||||
- **read_file**: Read any file. Parameters: path (string)
|
||||
- **write_file**: Create or overwrite files. Parameters: path (string), content (string)
|
||||
- **edit_file**: Modify existing files. Parameters: path (string), find (string), replace (string)
|
||||
- **list_directory**: List files and directories. Parameters: path (string, optional), recursive (boolean, optional)
|
||||
- **search_files**: Find files. Parameters: pattern (string), content (string), path (string)
|
||||
- **delete_file**: Delete a file. Parameters: path (string), reason (string)
|
||||
|
||||
### Shell Commands
|
||||
- **bash**: Run shell commands. Parameters: command (string), timeout (number, optional)
|
||||
|
||||
## HOW TO WORK
|
||||
|
||||
1. **To create a file**: Call write_file with the path and complete content
|
||||
2. **To modify a file**: First call read_file, then call edit_file with exact find/replace strings
|
||||
3. **To run commands**: Call bash with the command string
|
||||
|
||||
## EXAMPLE
|
||||
|
||||
User: "Create a Python hello world"
|
||||
|
||||
Your action: Call the write_file tool:
|
||||
- path: "hello.py"
|
||||
- content: "#!/usr/bin/env python3\nprint('Hello, World!')\n"
|
||||
|
||||
Then provide a brief summary.
|
||||
|
||||
## RULES
|
||||
|
||||
1. **ALWAYS call tools** - Never just show code in text, always use write_file
|
||||
2. **Read before editing** - Use read_file before edit_file
|
||||
3. **Complete files** - write_file content must be the entire file
|
||||
4. **Be precise** - edit_file "find" must match exactly including whitespace
|
||||
5. **Act, don't describe** - Use tools to make changes, don't just explain what to do
|
||||
]]
|
||||
|
||||
--- Tool usage instructions appended to system prompt
|
||||
M.tool_instructions = [[
|
||||
## MANDATORY TOOL CALLING
|
||||
|
||||
You MUST call tools to perform actions. Your response should include tool calls, not code blocks.
|
||||
|
||||
When the user asks you to create a file:
|
||||
→ Call write_file with path and content parameters
|
||||
|
||||
When the user asks you to modify a file:
|
||||
→ Call read_file first, then call edit_file
|
||||
|
||||
When the user asks you to run a command:
|
||||
→ Call bash with the command
|
||||
|
||||
## REMEMBER
|
||||
|
||||
- Outputting code in triple backticks does NOT create a file
|
||||
- You must explicitly call write_file to create any file
|
||||
- After tool execution, provide only a brief summary
|
||||
- Do not repeat code that was written - just confirm what was done
|
||||
]]
|
||||
|
||||
--- Prompt for when agent finishes
|
||||
M.completion = [[Provide a concise summary of what was changed.
|
||||
|
||||
Include:
|
||||
- Files that were read or modified
|
||||
- The nature of the changes (high-level)
|
||||
- Any follow-up steps or recommendations, if applicable
|
||||
|
||||
Do NOT restate tool output verbatim.
|
||||
]]
|
||||
|
||||
--- Text-based tool calling instructions
|
||||
M.tool_instructions_text = [[
|
||||
|
||||
## Available Tools
|
||||
Call tools by outputting JSON in this format:
|
||||
```json
|
||||
{"tool": "tool_name", "arguments": {...}}
|
||||
```
|
||||
]]
|
||||
|
||||
--- Initial greeting when files are provided
|
||||
M.initial_assistant_message = "I've reviewed the provided files. What would you like me to do?"
|
||||
|
||||
--- Format prefixes for text-based models
|
||||
M.text_user_prefix = "User: "
|
||||
M.text_assistant_prefix = "Assistant: "
|
||||
|
||||
--- Format file context
|
||||
---@param files string[] Paths
|
||||
---@return string Formatted context
|
||||
function M.format_file_context(files)
|
||||
local context = "# Initial Files\n"
|
||||
for _, file_path in ipairs(files) do
|
||||
local content = table.concat(vim.fn.readfile(file_path) or {}, "\n")
|
||||
context = context .. string.format("\n## %s\n```\n%s\n```\n", file_path, content)
|
||||
end
|
||||
return context
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,53 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.intent Intent-specific system prompts
|
||||
local M = {}
|
||||
|
||||
M.modifiers = {
|
||||
complete = [[
|
||||
You are completing an incomplete function.
|
||||
Return the complete function with all missing parts filled in.
|
||||
Keep the existing signature unless changes are required.
|
||||
Output only the code, no explanations.]],
|
||||
|
||||
refactor = [[
|
||||
You are refactoring existing code.
|
||||
Improve the code structure while maintaining the same behavior.
|
||||
Keep the function signature unchanged.
|
||||
Output only the refactored code, no explanations.]],
|
||||
|
||||
fix = [[
|
||||
You are fixing a bug in the code.
|
||||
Identify and correct the issue while minimizing changes.
|
||||
Preserve the original intent of the code.
|
||||
Output only the fixed code, no explanations.]],
|
||||
|
||||
add = [[
|
||||
You are adding new code.
|
||||
Follow the existing code style and conventions.
|
||||
Output only the new code to be inserted, no explanations.]],
|
||||
|
||||
document = [[
|
||||
You are adding documentation to the code.
|
||||
Add appropriate comments/docstrings for the function.
|
||||
Include parameter types, return types, and description.
|
||||
Output the complete function with documentation.]],
|
||||
|
||||
test = [[
|
||||
You are generating tests for the code.
|
||||
Create comprehensive unit tests covering edge cases.
|
||||
Follow the testing conventions of the project.
|
||||
Output only the test code, no explanations.]],
|
||||
|
||||
optimize = [[
|
||||
You are optimizing code for performance.
|
||||
Improve efficiency while maintaining correctness.
|
||||
Document any significant algorithmic changes.
|
||||
Output only the optimized code, no explanations.]],
|
||||
|
||||
explain = [[
|
||||
You are explaining code to a developer.
|
||||
Provide a clear, concise explanation of what the code does.
|
||||
Include information about the algorithm and any edge cases.
|
||||
Do not output code, only explanation.]],
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,13 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.linter Linter prompts
|
||||
local M = {}
|
||||
|
||||
M.fix_request = [[
|
||||
Fix the following linter errors in this code:
|
||||
|
||||
ERRORS:
|
||||
%s
|
||||
|
||||
CODE (lines %d-%d):
|
||||
%s]]
|
||||
|
||||
return M
|
||||
@@ -1,55 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.loop Agent Loop prompts
|
||||
local M = {}
|
||||
|
||||
M.default_system_prompt = [[You are a helpful coding assistant with access to tools.
|
||||
|
||||
Available tools:
|
||||
- view: Read file contents
|
||||
- grep: Search for patterns in files
|
||||
- glob: Find files by pattern
|
||||
- edit: Make targeted edits to files
|
||||
- write: Create or overwrite files
|
||||
- bash: Execute shell commands
|
||||
|
||||
When you need to perform a task:
|
||||
1. Use tools to gather information
|
||||
2. Plan your approach
|
||||
3. Execute changes using appropriate tools
|
||||
4. Verify the results
|
||||
|
||||
Always explain your reasoning before using tools.
|
||||
When you're done, provide a clear summary of what was accomplished.]]
|
||||
|
||||
M.dispatch_prompt = [[You are a research assistant. Your task is to find information and report back.
|
||||
You have access to: view (read files), grep (search content), glob (find files).
|
||||
Be thorough and report your findings clearly.]]
|
||||
|
||||
### File Operations
|
||||
- **read_file**: Read any file. Parameters: path (string)
|
||||
- **write_file**: Create or overwrite files. Parameters: path (string), content (string)
|
||||
- **edit_file**: Modify existing files. Parameters: path (string), find (string), replace (string)
|
||||
- **list_directory**: List files and directories. Parameters: path (string, optional), recursive (boolean, optional)
|
||||
- **search_files**: Find files. Parameters: pattern (string), content (string), path (string)
|
||||
- **delete_file**: Delete a file. Parameters: path (string), reason (string)
|
||||
|
||||
### Shell Commands
|
||||
- **bash**: Run shell commands. Parameters: command (string)
|
||||
|
||||
## WORKFLOW
|
||||
|
||||
1. **Analyze**: Understand the user's request.
|
||||
2. **Explore**: Use `list_directory`, `search_files`, or `read_file` to find relevant files.
|
||||
3. **Plan**: Think about what needs to be changed.
|
||||
4. **Execute**: Use `edit_file`, `write_file`, or `bash` to apply changes.
|
||||
5. **Verify**: You can check files after editing.
|
||||
|
||||
Always verify context before making changes.
|
||||
]]
|
||||
|
||||
M.dispatch_prompt = [[
|
||||
You are a research assistant. Your job is to explore the codebase and answer the user's question or find specific information.
|
||||
You have access to: view (read files), grep (search content), glob (find files).
|
||||
Be thorough and report your findings clearly.
|
||||
]]
|
||||
|
||||
return M
|
||||
@@ -1,14 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.modal Prompts and UI strings for context modal
|
||||
local M = {}
|
||||
|
||||
--- Modal UI strings
|
||||
M.ui = {
|
||||
files_header = { "", "-- No files detected in LLM response --" },
|
||||
llm_response_header = "-- LLM Response: --",
|
||||
suggested_commands_header = "-- Suggested commands: --",
|
||||
commands_hint = "-- Press <leader><n> to run a command, or <leader>r to run all --",
|
||||
input_header = "-- Enter additional context below (Ctrl-Enter to submit, Esc to cancel) --",
|
||||
project_inspect_header = { "", "-- Project inspection results --" },
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,58 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.personas Built-in agent personas
|
||||
local M = {}
|
||||
|
||||
M.builtin = {
|
||||
coder = {
|
||||
name = "coder",
|
||||
description = "Full-featured coding agent with file modification capabilities",
|
||||
system_prompt = [[You are an expert software engineer. You have access to tools to read, write, and modify files.
|
||||
|
||||
## Your Capabilities
|
||||
- Read files to understand the codebase
|
||||
- Search for patterns with grep and glob
|
||||
- Create new files with write tool
|
||||
- Edit existing files with precise replacements
|
||||
- Execute shell commands for builds and tests
|
||||
|
||||
## Guidelines
|
||||
1. Always read relevant files before making changes
|
||||
2. Make minimal, focused changes
|
||||
3. Follow existing code style and patterns
|
||||
4. Create tests when adding new functionality
|
||||
5. Verify changes work by running tests or builds
|
||||
|
||||
## Important Rules
|
||||
- NEVER guess file contents - always read first
|
||||
- Make precise edits using exact string matching
|
||||
- Explain your reasoning before making changes
|
||||
- If unsure, ask for clarification]],
|
||||
tools = { "view", "edit", "write", "grep", "glob", "bash" },
|
||||
},
|
||||
planner = {
|
||||
name = "planner",
|
||||
description = "Planning agent - read-only, helps design implementations",
|
||||
system_prompt = [[You are a software architect. Analyze codebases and create implementation plans.
|
||||
|
||||
You can read files and search the codebase, but cannot modify files.
|
||||
Your role is to:
|
||||
1. Understand the existing architecture
|
||||
2. Identify relevant files and patterns
|
||||
3. Create step-by-step implementation plans
|
||||
4. Suggest which files to modify and how
|
||||
|
||||
Be thorough in your analysis before making recommendations.]],
|
||||
tools = { "view", "grep", "glob" },
|
||||
},
|
||||
explorer = {
|
||||
name = "explorer",
|
||||
description = "Exploration agent - quickly find information in codebase",
|
||||
system_prompt = [[You are a codebase exploration assistant. Find information quickly and report back.
|
||||
|
||||
Your goal is to efficiently search and summarize findings.
|
||||
Use glob to find files, grep to search content, and view to read specific files.
|
||||
Be concise and focused in your responses.]],
|
||||
tools = { "view", "grep", "glob" },
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,12 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.scheduler Scheduler prompts
|
||||
local M = {}
|
||||
|
||||
M.retry_context = [[
|
||||
You requested more context for this task.
|
||||
Here is the additional information:
|
||||
%s
|
||||
|
||||
Please restart the task with this new context.
|
||||
]]
|
||||
|
||||
return M
|
||||
@@ -1,51 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.templates Agent and Rule templates
|
||||
local M = {}
|
||||
|
||||
M.agent = [[---
|
||||
description: Example custom agent
|
||||
tools: view,grep,glob,edit,write
|
||||
model:
|
||||
---
|
||||
|
||||
# Custom Agent
|
||||
|
||||
You are a custom coding agent. Describe your specialized behavior here.
|
||||
|
||||
## Your Role
|
||||
- Define what this agent specializes in
|
||||
- List specific capabilities
|
||||
|
||||
## Guidelines
|
||||
- Add agent-specific rules
|
||||
- Define coding standards to follow
|
||||
|
||||
## Examples
|
||||
Provide examples of how to handle common tasks.
|
||||
]]
|
||||
|
||||
M.rule = [[# Code Style
|
||||
|
||||
Follow these coding standards:
|
||||
|
||||
## General
|
||||
- Use consistent indentation (tabs or spaces based on project)
|
||||
- Keep lines under 100 characters
|
||||
- Add comments for complex logic
|
||||
|
||||
## Naming Conventions
|
||||
- Use descriptive variable names
|
||||
- Functions should be verbs (e.g., getUserData, calculateTotal)
|
||||
- Constants in UPPER_SNAKE_CASE
|
||||
|
||||
## Testing
|
||||
- Write tests for new functionality
|
||||
- Aim for >80% code coverage
|
||||
- Test edge cases
|
||||
|
||||
## Documentation
|
||||
- Document public APIs
|
||||
- Include usage examples
|
||||
- Keep docs up to date with code
|
||||
]]
|
||||
|
||||
return M
|
||||
@@ -1,18 +0,0 @@
|
||||
---@mod codetyper.prompts.agents.tools Tool system prompts
|
||||
local M = {}
|
||||
|
||||
M.instructions = {
|
||||
intro = "You have access to the following tools. To use a tool, respond with a JSON block.",
|
||||
header = "To call a tool, output a JSON block like this:",
|
||||
example = [[
|
||||
```json
|
||||
{"tool": "tool_name", "parameters": {"param1": "value1"}}
|
||||
```
|
||||
]],
|
||||
footer = [[
|
||||
After receiving tool results, continue your response or call another tool.
|
||||
When you're done, just respond normally without any tool calls.
|
||||
]],
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -1,11 +0,0 @@
|
||||
local M = {}
|
||||
|
||||
M.description = [[Reads the content of a file.
|
||||
|
||||
Usage notes:
|
||||
- Provide the file path relative to the project root
|
||||
- Use start_line and end_line to read specific sections
|
||||
- If content is truncated, use line ranges to read in chunks
|
||||
- Returns JSON with content, total_line_count, and is_truncated]]
|
||||
|
||||
return M
|
||||
@@ -1,8 +0,0 @@
|
||||
M.description = [[Creates or overwrites a file with new content.
|
||||
|
||||
IMPORTANT:
|
||||
- This will completely replace the file contents
|
||||
- Use 'edit' tool for partial modifications
|
||||
- Parent directories will be created if needed]]
|
||||
|
||||
return M
|
||||
@@ -1,177 +0,0 @@
|
||||
---@mod codetyper.prompts.ask Ask / explanation prompts for Codetyper.nvim
|
||||
---
|
||||
--- These prompts are used for the Ask panel and non-destructive explanations.
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Prompt for explaining code
|
||||
M.explain_code = [[You are explaining EXISTING code to a developer.
|
||||
|
||||
Code:
|
||||
{{code}}
|
||||
|
||||
Instructions:
|
||||
- Start with a concise high-level overview
|
||||
- Explain important logic and structure
|
||||
- Point out noteworthy implementation details
|
||||
- Mention potential issues or limitations ONLY if clearly visible
|
||||
- Do NOT speculate about missing context
|
||||
|
||||
Format the response in markdown.
|
||||
]]
|
||||
|
||||
--- Prompt for explaining a specific function
|
||||
M.explain_function = [[You are explaining an EXISTING function.
|
||||
|
||||
Function code:
|
||||
{{code}}
|
||||
|
||||
Explain:
|
||||
- What the function does and when it is used
|
||||
- The purpose of each parameter
|
||||
- The return value, if any
|
||||
- Side effects or assumptions
|
||||
- A brief usage example if appropriate
|
||||
|
||||
Format the response in markdown.
|
||||
Do NOT suggest refactors unless explicitly asked.
|
||||
]]
|
||||
|
||||
--- Prompt for explaining an error
|
||||
M.explain_error = [[You are helping diagnose a real error.
|
||||
|
||||
Error message:
|
||||
{{error}}
|
||||
|
||||
Relevant code:
|
||||
{{code}}
|
||||
|
||||
Instructions:
|
||||
- Explain what the error message means
|
||||
- Identify the most likely cause based on the code
|
||||
- Suggest concrete fixes or next debugging steps
|
||||
- If multiple causes are possible, say so clearly
|
||||
|
||||
Format the response in markdown.
|
||||
Do NOT invent missing stack traces or context.
|
||||
]]
|
||||
|
||||
--- Prompt for code review
|
||||
M.code_review = [[You are performing a code review on EXISTING code.
|
||||
|
||||
Code:
|
||||
{{code}}
|
||||
|
||||
Review criteria:
|
||||
- Readability and clarity
|
||||
- Correctness and potential bugs
|
||||
- Performance considerations where relevant
|
||||
- Security concerns only if applicable
|
||||
- Practical improvement suggestions
|
||||
|
||||
Guidelines:
|
||||
- Be constructive and specific
|
||||
- Do NOT nitpick style unless it impacts clarity
|
||||
- Do NOT suggest large refactors unless justified
|
||||
|
||||
Format the response in markdown.
|
||||
]]
|
||||
|
||||
--- Prompt for explaining a programming concept
|
||||
M.explain_concept = [[Explain the following programming concept to a developer:
|
||||
|
||||
Concept:
|
||||
{{concept}}
|
||||
|
||||
Include:
|
||||
- A clear definition and purpose
|
||||
- When and why it is used
|
||||
- A simple illustrative example
|
||||
- Common pitfalls or misconceptions
|
||||
|
||||
Format the response in markdown.
|
||||
Avoid unnecessary jargon.
|
||||
]]
|
||||
|
||||
--- Prompt for comparing approaches
|
||||
M.compare_approaches = [[Compare the following approaches:
|
||||
|
||||
{{approaches}}
|
||||
|
||||
Analysis guidelines:
|
||||
- Describe strengths and weaknesses of each
|
||||
- Discuss performance or complexity tradeoffs if relevant
|
||||
- Compare maintainability and clarity
|
||||
- Explain when one approach is preferable over another
|
||||
|
||||
Format the response in markdown.
|
||||
Base comparisons on general principles unless specific code is provided.
|
||||
]]
|
||||
|
||||
--- Prompt for debugging help
|
||||
M.debug_help = [[You are helping debug a concrete issue.
|
||||
|
||||
Problem description:
|
||||
{{problem}}
|
||||
|
||||
Code:
|
||||
{{code}}
|
||||
|
||||
What has already been tried:
|
||||
{{attempts}}
|
||||
|
||||
Instructions:
|
||||
- Identify likely root causes
|
||||
- Explain why the issue may be occurring
|
||||
- Suggest specific debugging steps or fixes
|
||||
- Call out missing information if needed
|
||||
|
||||
Format the response in markdown.
|
||||
Do NOT guess beyond the provided information.
|
||||
]]
|
||||
|
||||
--- Prompt for architecture advice
|
||||
M.architecture_advice = [[You are providing architecture guidance.
|
||||
|
||||
Question:
|
||||
{{question}}
|
||||
|
||||
Context:
|
||||
{{context}}
|
||||
|
||||
Instructions:
|
||||
- Recommend a primary approach
|
||||
- Explain the reasoning and tradeoffs
|
||||
- Mention viable alternatives when relevant
|
||||
- Highlight risks or constraints to consider
|
||||
|
||||
Format the response in markdown.
|
||||
Avoid dogmatic or one-size-fits-all answers.
|
||||
]]
|
||||
|
||||
--- Generic ask prompt
|
||||
M.generic = [[You are answering a developer's question.
|
||||
|
||||
Question:
|
||||
{{question}}
|
||||
|
||||
{{#if files}}
|
||||
Relevant file contents:
|
||||
{{files}}
|
||||
{{/if}}
|
||||
|
||||
{{#if context}}
|
||||
Additional context:
|
||||
{{context}}
|
||||
{{/if}}
|
||||
|
||||
Instructions:
|
||||
- Be accurate and grounded in the provided information
|
||||
- Clearly state assumptions or uncertainty
|
||||
- Prefer clarity over verbosity
|
||||
- Do NOT output raw code intended for insertion unless explicitly asked
|
||||
|
||||
Format the response in markdown.
|
||||
]]
|
||||
|
||||
return M
|
||||
@@ -11,7 +11,7 @@ M.code = require("codetyper.prompts.code")
|
||||
M.ask = require("codetyper.prompts.ask")
|
||||
M.refactor = require("codetyper.prompts.refactor")
|
||||
M.document = require("codetyper.prompts.document")
|
||||
M.agent = require("codetyper.prompts.agents")
|
||||
|
||||
|
||||
--- Get a prompt by category and name
|
||||
---@param category string Category name (system, code, ask, refactor, document)
|
||||
|
||||
Reference in New Issue
Block a user