Deleting unnecesary features

This commit is contained in:
2026-02-16 10:38:09 -05:00
parent 258b29f5f0
commit 4bef7e2d55
88 changed files with 7 additions and 19835 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ luac.out
*.i*86
*.x86_64
*.hex
.codetyper/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,10 +91,6 @@ end, {
"tree-view",
"reset",
"gitignore",
"ask",
"ask-close",
"ask-toggle",
"ask-clear",
}
end,
desc = "Codetyper.nvim commands",
@@ -131,18 +127,4 @@ api.nvim_create_user_command("CoderTreeView", function()
cmd("CoderTreeView")
end, { desc = "View tree.log" })
-- Ask panel commands
api.nvim_create_user_command("CoderAsk", function()
require("codetyper").setup()
cmd("CoderAsk")
end, { desc = "Open Ask panel" })
api.nvim_create_user_command("CoderAskToggle", function()
require("codetyper").setup()
cmd("CoderAskToggle")
end, { desc = "Toggle Ask panel" })
api.nvim_create_user_command("CoderAskClear", function()
require("codetyper").setup()
cmd("CoderAskClear")
end, { desc = "Clear Ask history" })

View File

@@ -44,5 +44,4 @@ require("codetyper").setup({
enabled = false, -- Disable scheduler during tests
},
auto_gitignore = false,
auto_open_ask = false,
})

View File

@@ -1,427 +0,0 @@
--- Tests for agent tools system
describe("codetyper.agent.tools", function()
local tools
before_each(function()
tools = require("codetyper.agent.tools")
-- Clear any existing registrations
for name, _ in pairs(tools.get_all()) do
tools.unregister(name)
end
end)
describe("tool registration", function()
it("should register a tool", function()
local test_tool = {
name = "test_tool",
description = "A test tool",
params = {
{ name = "input", type = "string", description = "Test input" },
},
func = function(input, opts)
return "result", nil
end,
}
tools.register(test_tool)
local retrieved = tools.get("test_tool")
assert.is_not_nil(retrieved)
assert.equals("test_tool", retrieved.name)
end)
it("should unregister a tool", function()
local test_tool = {
name = "temp_tool",
description = "Temporary",
func = function() end,
}
tools.register(test_tool)
assert.is_not_nil(tools.get("temp_tool"))
tools.unregister("temp_tool")
assert.is_nil(tools.get("temp_tool"))
end)
it("should list all tools", function()
tools.register({ name = "tool1", func = function() end })
tools.register({ name = "tool2", func = function() end })
tools.register({ name = "tool3", func = function() end })
local list = tools.list()
assert.equals(3, #list)
end)
it("should filter tools with predicate", function()
tools.register({ name = "safe_tool", requires_confirmation = false, func = function() end })
tools.register({ name = "dangerous_tool", requires_confirmation = true, func = function() end })
local safe_list = tools.list(function(t)
return not t.requires_confirmation
end)
assert.equals(1, #safe_list)
assert.equals("safe_tool", safe_list[1].name)
end)
end)
describe("tool execution", function()
it("should execute a tool and return result", function()
tools.register({
name = "adder",
params = {
{ name = "a", type = "number" },
{ name = "b", type = "number" },
},
func = function(input, opts)
return input.a + input.b, nil
end,
})
local result, err = tools.execute("adder", { a = 5, b = 3 }, {})
assert.is_nil(err)
assert.equals(8, result)
end)
it("should return error for unknown tool", function()
local result, err = tools.execute("nonexistent", {}, {})
assert.is_nil(result)
assert.truthy(err:match("Unknown tool"))
end)
it("should track execution history", function()
tools.clear_history()
tools.register({
name = "tracked_tool",
func = function()
return "done", nil
end,
})
tools.execute("tracked_tool", {}, {})
tools.execute("tracked_tool", {}, {})
local history = tools.get_history()
assert.equals(2, #history)
assert.equals("tracked_tool", history[1].tool)
assert.equals("completed", history[1].status)
end)
end)
describe("tool schemas", function()
it("should generate JSON schema for tools", function()
tools.register({
name = "schema_test",
description = "Test schema generation",
params = {
{ name = "required_param", type = "string", description = "A required param" },
{ name = "optional_param", type = "number", description = "Optional", optional = true },
},
returns = {
{ name = "result", type = "string" },
},
to_schema = require("codetyper.agent.tools.base").to_schema,
func = function() end,
})
local schemas = tools.get_schemas()
assert.equals(1, #schemas)
local schema = schemas[1]
assert.equals("function", schema.type)
assert.equals("schema_test", schema.function_def.name)
assert.is_not_nil(schema.function_def.parameters.properties.required_param)
assert.is_not_nil(schema.function_def.parameters.properties.optional_param)
end)
end)
describe("process_tool_call", function()
it("should process tool call with name and input", function()
tools.register({
name = "processor_test",
func = function(input, opts)
return "processed: " .. input.value, nil
end,
})
local result, err = tools.process_tool_call({
name = "processor_test",
input = { value = "test" },
}, {})
assert.is_nil(err)
assert.equals("processed: test", result)
end)
it("should parse JSON string arguments", function()
tools.register({
name = "json_parser_test",
func = function(input, opts)
return input.key, nil
end,
})
local result, err = tools.process_tool_call({
name = "json_parser_test",
arguments = '{"key": "value"}',
}, {})
assert.is_nil(err)
assert.equals("value", result)
end)
end)
end)
describe("codetyper.agent.tools.base", function()
local base
before_each(function()
base = require("codetyper.agent.tools.base")
end)
describe("validate_input", function()
it("should validate required parameters", function()
local tool = setmetatable({
params = {
{ name = "required", type = "string" },
{ name = "optional", type = "string", optional = true },
},
}, base)
local valid, err = tool:validate_input({ required = "value" })
assert.is_true(valid)
assert.is_nil(err)
end)
it("should fail on missing required parameter", function()
local tool = setmetatable({
params = {
{ name = "required", type = "string" },
},
}, base)
local valid, err = tool:validate_input({})
assert.is_false(valid)
assert.truthy(err:match("Missing required parameter"))
end)
it("should validate parameter types", function()
local tool = setmetatable({
params = {
{ name = "num", type = "number" },
},
}, base)
local valid1, _ = tool:validate_input({ num = 42 })
assert.is_true(valid1)
local valid2, err2 = tool:validate_input({ num = "not a number" })
assert.is_false(valid2)
assert.truthy(err2:match("must be number"))
end)
it("should validate integer type", function()
local tool = setmetatable({
params = {
{ name = "int", type = "integer" },
},
}, base)
local valid1, _ = tool:validate_input({ int = 42 })
assert.is_true(valid1)
local valid2, err2 = tool:validate_input({ int = 42.5 })
assert.is_false(valid2)
assert.truthy(err2:match("must be an integer"))
end)
end)
describe("get_description", function()
it("should return string description", function()
local tool = setmetatable({
description = "Static description",
}, base)
assert.equals("Static description", tool:get_description())
end)
it("should call function description", function()
local tool = setmetatable({
description = function()
return "Dynamic description"
end,
}, base)
assert.equals("Dynamic description", tool:get_description())
end)
end)
describe("to_schema", function()
it("should generate valid schema", function()
local tool = setmetatable({
name = "test",
description = "Test tool",
params = {
{ name = "input", type = "string", description = "Input value" },
{ name = "count", type = "integer", description = "Count", optional = true },
},
}, base)
local schema = tool:to_schema()
assert.equals("function", schema.type)
assert.equals("test", schema.function_def.name)
assert.equals("Test tool", schema.function_def.description)
assert.equals("object", schema.function_def.parameters.type)
assert.is_not_nil(schema.function_def.parameters.properties.input)
assert.is_not_nil(schema.function_def.parameters.properties.count)
assert.same({ "input" }, schema.function_def.parameters.required)
end)
end)
end)
describe("built-in tools", function()
describe("view tool", function()
local view
before_each(function()
view = require("codetyper.agent.tools.view")
end)
it("should have required fields", function()
assert.equals("view", view.name)
assert.is_string(view.description)
assert.is_table(view.params)
assert.is_function(view.func)
end)
it("should require path parameter", function()
local result, err = view.func({}, {})
assert.is_nil(result)
assert.truthy(err:match("path is required"))
end)
end)
describe("grep tool", function()
local grep
before_each(function()
grep = require("codetyper.agent.tools.grep")
end)
it("should have required fields", function()
assert.equals("grep", grep.name)
assert.is_string(grep.description)
assert.is_table(grep.params)
assert.is_function(grep.func)
end)
it("should require pattern parameter", function()
local result, err = grep.func({}, {})
assert.is_nil(result)
assert.truthy(err:match("pattern is required"))
end)
end)
describe("glob tool", function()
local glob
before_each(function()
glob = require("codetyper.agent.tools.glob")
end)
it("should have required fields", function()
assert.equals("glob", glob.name)
assert.is_string(glob.description)
assert.is_table(glob.params)
assert.is_function(glob.func)
end)
it("should require pattern parameter", function()
local result, err = glob.func({}, {})
assert.is_nil(result)
assert.truthy(err:match("pattern is required"))
end)
end)
describe("edit tool", function()
local edit
before_each(function()
edit = require("codetyper.agent.tools.edit")
end)
it("should have required fields", function()
assert.equals("edit", edit.name)
assert.is_string(edit.description)
assert.is_table(edit.params)
assert.is_function(edit.func)
end)
it("should require path parameter", function()
local result, err = edit.func({}, {})
assert.is_nil(result)
assert.truthy(err:match("path is required"))
end)
it("should require old_string parameter", function()
local result, err = edit.func({ path = "/tmp/test" }, {})
assert.is_nil(result)
assert.truthy(err:match("old_string is required"))
end)
end)
describe("write tool", function()
local write
before_each(function()
write = require("codetyper.agent.tools.write")
end)
it("should have required fields", function()
assert.equals("write", write.name)
assert.is_string(write.description)
assert.is_table(write.params)
assert.is_function(write.func)
end)
it("should require path parameter", function()
local result, err = write.func({}, {})
assert.is_nil(result)
assert.truthy(err:match("path is required"))
end)
it("should require content parameter", function()
local result, err = write.func({ path = "/tmp/test" }, {})
assert.is_nil(result)
assert.truthy(err:match("content is required"))
end)
end)
describe("bash tool", function()
local bash
before_each(function()
bash = require("codetyper.agent.tools.bash")
end)
it("should have required fields", function()
assert.equals("bash", bash.name)
assert.is_function(bash.func)
end)
it("should require command parameter", function()
local result, err = bash.func({}, {})
assert.is_nil(result)
assert.truthy(err:match("command is required"))
end)
it("should require confirmation by default", function()
assert.is_true(bash.requires_confirmation)
end)
end)
end)

View File

@@ -1,312 +0,0 @@
---@diagnostic disable: undefined-global
-- Unit tests for the agentic system
describe("agentic module", function()
local agentic
before_each(function()
-- Reset and reload
package.loaded["codetyper.agent.agentic"] = nil
agentic = require("codetyper.agent.agentic")
end)
it("should list built-in agents", function()
local agents = agentic.list_agents()
assert.is_table(agents)
assert.is_true(#agents >= 3) -- coder, planner, explorer
local names = {}
for _, agent in ipairs(agents) do
names[agent.name] = true
end
assert.is_true(names["coder"])
assert.is_true(names["planner"])
assert.is_true(names["explorer"])
end)
it("should have description for each agent", function()
local agents = agentic.list_agents()
for _, agent in ipairs(agents) do
assert.is_string(agent.description)
assert.is_true(#agent.description > 0)
end
end)
it("should mark built-in agents as builtin", function()
local agents = agentic.list_agents()
local coder = nil
for _, agent in ipairs(agents) do
if agent.name == "coder" then
coder = agent
break
end
end
assert.is_not_nil(coder)
assert.is_true(coder.builtin)
end)
it("should have init function to create directories", function()
assert.is_function(agentic.init)
assert.is_function(agentic.init_agents_dir)
assert.is_function(agentic.init_rules_dir)
end)
it("should have run function for executing tasks", function()
assert.is_function(agentic.run)
end)
end)
describe("tools format conversion", function()
local tools_module
before_each(function()
package.loaded["codetyper.agent.tools"] = nil
tools_module = require("codetyper.agent.tools")
-- Load tools
if tools_module.load_builtins then
pcall(tools_module.load_builtins)
end
end)
it("should have to_openai_format function", function()
assert.is_function(tools_module.to_openai_format)
end)
it("should have to_claude_format function", function()
assert.is_function(tools_module.to_claude_format)
end)
it("should convert tools to OpenAI format", function()
local openai_tools = tools_module.to_openai_format()
assert.is_table(openai_tools)
-- If tools are loaded, check format
if #openai_tools > 0 then
local first_tool = openai_tools[1]
assert.equals("function", first_tool.type)
assert.is_table(first_tool["function"])
assert.is_string(first_tool["function"].name)
end
end)
it("should convert tools to Claude format", function()
local claude_tools = tools_module.to_claude_format()
assert.is_table(claude_tools)
-- If tools are loaded, check format
if #claude_tools > 0 then
local first_tool = claude_tools[1]
assert.is_string(first_tool.name)
assert.is_table(first_tool.input_schema)
end
end)
end)
describe("edit tool", function()
local edit_tool
before_each(function()
package.loaded["codetyper.agent.tools.edit"] = nil
edit_tool = require("codetyper.agent.tools.edit")
end)
it("should have name 'edit'", function()
assert.equals("edit", edit_tool.name)
end)
it("should have description mentioning matching strategies", function()
local desc = edit_tool:get_description()
assert.is_string(desc)
-- Should mention the matching capabilities
assert.is_true(desc:lower():match("match") ~= nil or desc:lower():match("replac") ~= nil)
end)
it("should have params defined", function()
assert.is_table(edit_tool.params)
assert.is_true(#edit_tool.params >= 3) -- path, old_string, new_string
end)
it("should require path parameter", function()
local valid, err = edit_tool:validate_input({
old_string = "test",
new_string = "test2",
})
assert.is_false(valid)
assert.is_string(err)
end)
it("should require old_string parameter", function()
local valid, err = edit_tool:validate_input({
path = "/test",
new_string = "test",
})
assert.is_false(valid)
end)
it("should require new_string parameter", function()
local valid, err = edit_tool:validate_input({
path = "/test",
old_string = "test",
})
assert.is_false(valid)
end)
it("should accept empty old_string for new file creation", function()
local valid, err = edit_tool:validate_input({
path = "/test/new_file.lua",
old_string = "",
new_string = "new content",
})
assert.is_true(valid)
assert.is_nil(err)
end)
it("should have func implementation", function()
assert.is_function(edit_tool.func)
end)
end)
describe("view tool", function()
local view_tool
before_each(function()
package.loaded["codetyper.agent.tools.view"] = nil
view_tool = require("codetyper.agent.tools.view")
end)
it("should have name 'view'", function()
assert.equals("view", view_tool.name)
end)
it("should require path parameter", function()
local valid, err = view_tool:validate_input({})
assert.is_false(valid)
end)
it("should accept valid path", function()
local valid, err = view_tool:validate_input({
path = "/test/file.lua",
})
assert.is_true(valid)
end)
end)
describe("write tool", function()
local write_tool
before_each(function()
package.loaded["codetyper.agent.tools.write"] = nil
write_tool = require("codetyper.agent.tools.write")
end)
it("should have name 'write'", function()
assert.equals("write", write_tool.name)
end)
it("should require path and content parameters", function()
local valid, err = write_tool:validate_input({})
assert.is_false(valid)
valid, err = write_tool:validate_input({ path = "/test" })
assert.is_false(valid)
end)
it("should accept valid input", function()
local valid, err = write_tool:validate_input({
path = "/test/file.lua",
content = "test content",
})
assert.is_true(valid)
end)
end)
describe("grep tool", function()
local grep_tool
before_each(function()
package.loaded["codetyper.agent.tools.grep"] = nil
grep_tool = require("codetyper.agent.tools.grep")
end)
it("should have name 'grep'", function()
assert.equals("grep", grep_tool.name)
end)
it("should require pattern parameter", function()
local valid, err = grep_tool:validate_input({})
assert.is_false(valid)
end)
it("should accept valid pattern", function()
local valid, err = grep_tool:validate_input({
pattern = "function.*test",
})
assert.is_true(valid)
end)
end)
describe("glob tool", function()
local glob_tool
before_each(function()
package.loaded["codetyper.agent.tools.glob"] = nil
glob_tool = require("codetyper.agent.tools.glob")
end)
it("should have name 'glob'", function()
assert.equals("glob", glob_tool.name)
end)
it("should require pattern parameter", function()
local valid, err = glob_tool:validate_input({})
assert.is_false(valid)
end)
it("should accept valid pattern", function()
local valid, err = glob_tool:validate_input({
pattern = "**/*.lua",
})
assert.is_true(valid)
end)
end)
describe("base tool", function()
local Base
before_each(function()
package.loaded["codetyper.agent.tools.base"] = nil
Base = require("codetyper.agent.tools.base")
end)
it("should have validate_input method", function()
assert.is_function(Base.validate_input)
end)
it("should have to_schema method", function()
assert.is_function(Base.to_schema)
end)
it("should have get_description method", function()
assert.is_function(Base.get_description)
end)
it("should generate valid schema", function()
local test_tool = setmetatable({
name = "test",
description = "A test tool",
params = {
{ name = "arg1", type = "string", description = "First arg" },
{ name = "arg2", type = "number", description = "Second arg", optional = true },
},
}, Base)
local schema = test_tool:to_schema()
assert.equals("function", schema.type)
assert.equals("test", schema.function_def.name)
assert.is_table(schema.function_def.parameters.properties)
assert.is_table(schema.function_def.parameters.required)
assert.is_true(vim.tbl_contains(schema.function_def.parameters.required, "arg1"))
assert.is_false(vim.tbl_contains(schema.function_def.parameters.required, "arg2"))
end)
end)

View File

@@ -1,229 +0,0 @@
--- Tests for ask intent detection
local intent = require("codetyper.ask.intent")
describe("ask.intent", function()
describe("detect", function()
-- Ask/Explain intent tests
describe("ask intent", function()
it("detects 'what' questions as ask", function()
local result = intent.detect("What does this function do?")
assert.equals("ask", result.type)
assert.is_true(result.confidence > 0.3)
end)
it("detects 'why' questions as ask", function()
local result = intent.detect("Why is this variable undefined?")
assert.equals("ask", result.type)
end)
it("detects 'how does' as ask", function()
local result = intent.detect("How does this algorithm work?")
assert.is_true(result.type == "ask" or result.type == "explain")
end)
it("detects 'explain' requests as explain", function()
local result = intent.detect("Explain me the project structure")
assert.equals("explain", result.type)
assert.is_true(result.confidence > 0.4)
end)
it("detects 'walk me through' as explain", function()
local result = intent.detect("Walk me through this code")
assert.equals("explain", result.type)
end)
it("detects questions ending with ? as likely ask", function()
local result = intent.detect("Is this the right approach?")
assert.equals("ask", result.type)
end)
it("sets needs_brain_context for ask intent", function()
local result = intent.detect("What patterns are used here?")
assert.is_true(result.needs_brain_context)
end)
end)
-- Generate intent tests
describe("generate intent", function()
it("detects 'create' commands as generate", function()
local result = intent.detect("Create a function to sort arrays")
assert.equals("generate", result.type)
end)
it("detects 'write' commands as generate", function()
local result = intent.detect("Write a unit test for this module")
-- Could be generate or test
assert.is_true(result.type == "generate" or result.type == "test")
end)
it("detects 'implement' as generate", function()
local result = intent.detect("Implement a binary search")
assert.equals("generate", result.type)
assert.is_true(result.confidence > 0.4)
end)
it("detects 'add' commands as generate", function()
local result = intent.detect("Add error handling to this function")
assert.equals("generate", result.type)
end)
it("detects 'fix' as generate", function()
local result = intent.detect("Fix the bug in line 42")
assert.equals("generate", result.type)
end)
end)
-- Refactor intent tests
describe("refactor intent", function()
it("detects explicit 'refactor' as refactor", function()
local result = intent.detect("Refactor this function")
assert.equals("refactor", result.type)
end)
it("detects 'clean up' as refactor", function()
local result = intent.detect("Clean up this messy code")
assert.equals("refactor", result.type)
end)
it("detects 'simplify' as refactor", function()
local result = intent.detect("Simplify this logic")
assert.equals("refactor", result.type)
end)
end)
-- Document intent tests
describe("document intent", function()
it("detects 'document' as document", function()
local result = intent.detect("Document this function")
assert.equals("document", result.type)
end)
it("detects 'add documentation' as document", function()
local result = intent.detect("Add documentation to this class")
assert.equals("document", result.type)
end)
it("detects 'add jsdoc' as document", function()
local result = intent.detect("Add jsdoc comments")
assert.equals("document", result.type)
end)
end)
-- Test intent tests
describe("test intent", function()
it("detects 'write tests for' as test", function()
local result = intent.detect("Write tests for this module")
assert.equals("test", result.type)
end)
it("detects 'add unit tests' as test", function()
local result = intent.detect("Add unit tests for the parser")
assert.equals("test", result.type)
end)
it("detects 'generate tests' as test", function()
local result = intent.detect("Generate tests for the API")
assert.equals("test", result.type)
end)
end)
-- Project context tests
describe("project context detection", function()
it("detects 'project' as needing project context", function()
local result = intent.detect("Explain the project architecture")
assert.is_true(result.needs_project_context)
end)
it("detects 'codebase' as needing project context", function()
local result = intent.detect("How is the codebase organized?")
assert.is_true(result.needs_project_context)
end)
it("does not need project context for simple questions", function()
local result = intent.detect("What does this variable mean?")
assert.is_false(result.needs_project_context)
end)
end)
-- Exploration tests
describe("exploration detection", function()
it("detects 'explain me the project' as needing exploration", function()
local result = intent.detect("Explain me the project")
assert.is_true(result.needs_exploration)
end)
it("detects 'explain the codebase' as needing exploration", function()
local result = intent.detect("Explain the codebase structure")
assert.is_true(result.needs_exploration)
end)
it("detects 'explore project' as needing exploration", function()
local result = intent.detect("Explore this project")
assert.is_true(result.needs_exploration)
end)
it("does not need exploration for simple questions", function()
local result = intent.detect("What does this function do?")
assert.is_false(result.needs_exploration)
end)
end)
end)
describe("get_prompt_type", function()
it("maps ask to ask", function()
local result = intent.get_prompt_type({ type = "ask" })
assert.equals("ask", result)
end)
it("maps explain to ask", function()
local result = intent.get_prompt_type({ type = "explain" })
assert.equals("ask", result)
end)
it("maps generate to code_generation", function()
local result = intent.get_prompt_type({ type = "generate" })
assert.equals("code_generation", result)
end)
it("maps refactor to refactor", function()
local result = intent.get_prompt_type({ type = "refactor" })
assert.equals("refactor", result)
end)
it("maps document to document", function()
local result = intent.get_prompt_type({ type = "document" })
assert.equals("document", result)
end)
it("maps test to test", function()
local result = intent.get_prompt_type({ type = "test" })
assert.equals("test", result)
end)
end)
describe("produces_code", function()
it("returns false for ask", function()
assert.is_false(intent.produces_code({ type = "ask" }))
end)
it("returns false for explain", function()
assert.is_false(intent.produces_code({ type = "explain" }))
end)
it("returns true for generate", function()
assert.is_true(intent.produces_code({ type = "generate" }))
end)
it("returns true for refactor", function()
assert.is_true(intent.produces_code({ type = "refactor" }))
end)
it("returns true for document", function()
assert.is_true(intent.produces_code({ type = "document" }))
end)
it("returns true for test", function()
assert.is_true(intent.produces_code({ type = "test" }))
end)
end)
end)