diff --git a/.gitignore b/.gitignore index 9995ad4..67e03a1 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ luac.out *.i*86 *.x86_64 *.hex +.codetyper/ diff --git a/lua/codetyper/adapters/nvim/commands.lua b/lua/codetyper/adapters/nvim/commands.lua index 1c6e8c2..70bcfdb 100644 --- a/lua/codetyper/adapters/nvim/commands.lua +++ b/lua/codetyper/adapters/nvim/commands.lua @@ -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 [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 [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", "ca", "CoderAgentToggle", { - silent = true, - desc = "Coder: Toggle Agent panel" - }) - -- Index keymap - open coder companion vim.keymap.set("n", "ci", "CoderIndex", { silent = true, diff --git a/lua/codetyper/adapters/nvim/ui/chat.lua b/lua/codetyper/adapters/nvim/ui/chat.lua deleted file mode 100644 index 0b697c3..0000000 --- a/lua/codetyper/adapters/nvim/ui/chat.lua +++ /dev/null @@ -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 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 | 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 | 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", "", submit_input, input_opts) - vim.keymap.set("n", "", submit_input, input_opts) - vim.keymap.set("i", "@", M.show_file_picker, input_opts) - vim.keymap.set({ "n", "i" }, "", M.include_current_file, input_opts) - vim.keymap.set("n", "", M.focus_chat, input_opts) - vim.keymap.set("n", "q", M.close, input_opts) - vim.keymap.set("n", "", M.close, input_opts) - vim.keymap.set("n", "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", "", M.focus_input, chat_opts) - vim.keymap.set("n", "@", M.show_file_picker, chat_opts) - vim.keymap.set("n", "", M.include_current_file, chat_opts) - vim.keymap.set("n", "", M.focus_logs, chat_opts) - vim.keymap.set("n", "q", M.close, chat_opts) - vim.keymap.set("n", "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", "", 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 diff --git a/lua/codetyper/adapters/nvim/ui/context_modal.lua b/lua/codetyper/adapters/nvim/ui/context_modal.lua deleted file mode 100644 index 0aa98e3..0000000 --- a/lua/codetyper/adapters/nvim/ui/context_modal.lua +++ /dev/null @@ -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 s - vim.keymap.set("n", "", submit, opts) - vim.keymap.set("i", "", submit, opts) - vim.keymap.set("n", "s", submit, opts) - vim.keymap.set("n", "", 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 r / in insert mode - vim.keymap.set("n", "r", run_project_inspect, opts) - vim.keymap.set("i", "", function() - vim.schedule(run_project_inspect) - end, { buffer = state.buf, noremap = true, silent = true }) - - -- If suggested commands provided, create per-command keymaps 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 = "" .. 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 0 to run all suggested commands - vim.keymap.set("n", "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", "", 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", "r", run_project_inspect, { buffer = state.buf, noremap = true, silent = true }) - vim.keymap.set("i", "", 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 diff --git a/lua/codetyper/adapters/nvim/ui/diff_review.lua b/lua/codetyper/adapters/nvim/ui/diff_review.lua deleted file mode 100644 index d124360..0000000 --- a/lua/codetyper/adapters/nvim/ui/diff_review.lua +++ /dev/null @@ -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", "", M.next, list_opts) - vim.keymap.set("n", "", M.prev, list_opts) - vim.keymap.set("n", "", 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", "", 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", "", 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", "", 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 diff --git a/lua/codetyper/adapters/nvim/ui/logs.lua b/lua/codetyper/adapters/nvim/ui/logs.lua deleted file mode 100644 index ce904ee..0000000 --- a/lua/codetyper/adapters/nvim/ui/logs.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/adapters/nvim/ui/logs_panel.lua b/lua/codetyper/adapters/nvim/ui/logs_panel.lua deleted file mode 100644 index 25cdfb4..0000000 --- a/lua/codetyper/adapters/nvim/ui/logs_panel.lua +++ /dev/null @@ -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", "", 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", "", 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 diff --git a/lua/codetyper/adapters/nvim/ui/switcher.lua b/lua/codetyper/adapters/nvim/ui/switcher.lua deleted file mode 100644 index c65ad05..0000000 --- a/lua/codetyper/adapters/nvim/ui/switcher.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/commands/agents/banned.lua b/lua/codetyper/commands/agents/banned.lua deleted file mode 100644 index ad5eadc..0000000 --- a/lua/codetyper/commands/agents/banned.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/config/defaults.lua b/lua/codetyper/config/defaults.lua index dd98ffb..0ed61b2 100644 --- a/lua/codetyper/config/defaults.lua +++ b/lua/codetyper/config/defaults.lua @@ -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 diff --git a/lua/codetyper/core/diff/conflict.lua b/lua/codetyper/core/diff/conflict.lua deleted file mode 100644 index 7465ad8..0000000 --- a/lua/codetyper/core/diff/conflict.lua +++ /dev/null @@ -1,1052 +0,0 @@ ----@mod codetyper.agent.conflict Git conflict-style diff visualization ----@brief [[ ---- Provides interactive conflict resolution for AI-generated code changes. ---- Uses git merge conflict markers (<<<<<<< / ======= / >>>>>>>) with ---- extmark highlighting for visual differentiation. ---- ---- Keybindings in conflict buffers: ---- co = accept "ours" (keep original code) ---- ct = accept "theirs" (use AI suggestion) ---- cb = accept "both" (keep both versions) ---- cn = accept "none" (delete both versions) ---- [x = jump to previous conflict ---- ]x = jump to next conflict ----@brief ]] - -local M = {} - -local params = require("codetyper.params.agents.conflict") - ---- Lazy load linter module -local function get_linter() - return require("codetyper.features.agents.linter") -end - ---- Configuration -local config = vim.deepcopy(params.config) - ---- Namespace for conflict highlighting -local NAMESPACE = vim.api.nvim_create_namespace("codetyper_conflict") - ---- Namespace for keybinding hints -local HINT_NAMESPACE = vim.api.nvim_create_namespace("codetyper_conflict_hints") - ---- Highlight groups -local HL_GROUPS = params.hl_groups - ---- Conflict markers -local MARKERS = params.markers - ---- Track buffers with active conflicts -local conflict_buffers = {} - ---- Run linter validation after accepting code changes ----@param bufnr number Buffer number ----@param start_line number Start line of changed region ----@param end_line number End line of changed region ----@param accepted_type string Type of acceptance ("theirs", "both") -local function validate_after_accept(bufnr, start_line, end_line, accepted_type) - if not config.lint_after_accept then - return - end - - -- Only validate when accepting AI suggestions - if accepted_type ~= "theirs" and accepted_type ~= "both" then - return - end - - local linter = get_linter() - - -- Validate the changed region - linter.validate_after_injection(bufnr, start_line, end_line, function(result) - if not result then - return - end - - -- If errors found and auto-fix is enabled, queue fix automatically - if result.has_errors and config.auto_fix_lint_errors then - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = "Auto-queuing fix for lint errors...", - }) - end) - linter.request_ai_fix(bufnr, result) - end - end) -end - ---- Configure conflict 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 - ---- Auto-show menu for next conflict if enabled and conflicts remain ----@param bufnr number Buffer number -local function auto_show_next_conflict_menu(bufnr) - if not config.auto_show_next_menu then - return - end - - vim.schedule(function() - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - local conflicts = M.detect_conflicts(bufnr) - if #conflicts > 0 then - -- Jump to first remaining conflict and show menu - local conflict = conflicts[1] - local win = vim.api.nvim_get_current_win() - if vim.api.nvim_win_get_buf(win) == bufnr then - vim.api.nvim_win_set_cursor(win, { conflict.start_line, 0 }) - vim.cmd("normal! zz") - M.show_floating_menu(bufnr) - end - end - end) -end - ---- Setup highlight groups -local function setup_highlights() - -- Current (original) code - green tint - vim.api.nvim_set_hl(0, HL_GROUPS.current, { - bg = "#2d4a3e", - default = true, - }) - vim.api.nvim_set_hl(0, HL_GROUPS.current_label, { - fg = "#98c379", - bg = "#2d4a3e", - bold = true, - default = true, - }) - - -- Incoming (AI suggestion) code - blue tint - vim.api.nvim_set_hl(0, HL_GROUPS.incoming, { - bg = "#2d3a4a", - default = true, - }) - vim.api.nvim_set_hl(0, HL_GROUPS.incoming_label, { - fg = "#61afef", - bg = "#2d3a4a", - bold = true, - default = true, - }) - - -- Separator line - vim.api.nvim_set_hl(0, HL_GROUPS.separator, { - fg = "#5c6370", - bg = "#3e4451", - bold = true, - default = true, - }) - - -- Keybinding hints - vim.api.nvim_set_hl(0, HL_GROUPS.hint, { - fg = "#5c6370", - italic = true, - default = true, - }) -end - ---- Parse a buffer and find all conflict regions ----@param bufnr number Buffer number ----@return table[] conflicts List of conflict positions -function M.detect_conflicts(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return {} - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local conflicts = {} - local current_conflict = nil - - for i, line in ipairs(lines) do - if line:match("^<<<<<<<") then - current_conflict = { - start_line = i, - current_start = i, - current_end = nil, - separator = nil, - incoming_start = nil, - incoming_end = nil, - end_line = nil, - } - elseif line:match("^=======") and current_conflict then - current_conflict.current_end = i - 1 - current_conflict.separator = i - current_conflict.incoming_start = i + 1 - elseif line:match("^>>>>>>>") and current_conflict then - current_conflict.incoming_end = i - 1 - current_conflict.end_line = i - table.insert(conflicts, current_conflict) - current_conflict = nil - end - end - - return conflicts -end - ---- Highlight conflicts in buffer using extmarks ----@param bufnr number Buffer number ----@param conflicts table[] Conflict positions -function M.highlight_conflicts(bufnr, conflicts) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - -- Clear existing highlights - vim.api.nvim_buf_clear_namespace(bufnr, NAMESPACE, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, HINT_NAMESPACE, 0, -1) - - for _, conflict in ipairs(conflicts) do - -- Highlight <<<<<<< CURRENT line - vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, conflict.start_line - 1, 0, { - end_row = conflict.start_line - 1, - end_col = 0, - line_hl_group = HL_GROUPS.current_label, - priority = 100, - }) - - -- Highlight current (original) code section - if conflict.current_start and conflict.current_end then - for row = conflict.current_start, conflict.current_end do - if row <= conflict.current_end then - vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, row - 1, 0, { - end_row = row - 1, - end_col = 0, - line_hl_group = HL_GROUPS.current, - priority = 90, - }) - end - end - end - - -- Highlight ======= separator - if conflict.separator then - vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, conflict.separator - 1, 0, { - end_row = conflict.separator - 1, - end_col = 0, - line_hl_group = HL_GROUPS.separator, - priority = 100, - }) - end - - -- Highlight incoming (AI suggestion) code section - if conflict.incoming_start and conflict.incoming_end then - for row = conflict.incoming_start, conflict.incoming_end do - if row <= conflict.incoming_end then - vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, row - 1, 0, { - end_row = row - 1, - end_col = 0, - line_hl_group = HL_GROUPS.incoming, - priority = 90, - }) - end - end - end - - -- Highlight >>>>>>> INCOMING line - if conflict.end_line then - vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, conflict.end_line - 1, 0, { - end_row = conflict.end_line - 1, - end_col = 0, - line_hl_group = HL_GROUPS.incoming_label, - priority = 100, - }) - end - - -- Add virtual text hint on the <<<<<<< line - vim.api.nvim_buf_set_extmark(bufnr, HINT_NAMESPACE, conflict.start_line - 1, 0, { - virt_text = { - { " [co]=ours [ct]=theirs [cb]=both [cn]=none [x/]x=nav", HL_GROUPS.hint }, - }, - virt_text_pos = "eol", - priority = 50, - }) - end -end - ---- Get the conflict at the current cursor position ----@param bufnr number Buffer number ----@param cursor_line number Current line (1-indexed) ----@return table|nil conflict The conflict at cursor, or nil -function M.get_conflict_at_cursor(bufnr, cursor_line) - local conflicts = M.detect_conflicts(bufnr) - - for _, conflict in ipairs(conflicts) do - if cursor_line >= conflict.start_line and cursor_line <= conflict.end_line then - return conflict - end - end - - return nil -end - ---- Accept "ours" - keep the original code ----@param bufnr number Buffer number -function M.accept_ours(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) - - if not conflict then - vim.notify("No conflict at cursor position", vim.log.levels.WARN) - return - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Extract the "current" (original) lines - local keep_lines = {} - if conflict.current_start and conflict.current_end then - for i = conflict.current_start + 1, conflict.current_end do - table.insert(keep_lines, lines[i]) - end - end - - -- Replace the entire conflict region with the kept lines - vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, keep_lines) - - -- Re-process remaining conflicts - M.process(bufnr) - - vim.notify("Accepted CURRENT (original) code", vim.log.levels.INFO) - - -- Auto-show menu for next conflict if any remain - auto_show_next_conflict_menu(bufnr) -end - ---- Accept "theirs" - use the AI suggestion ----@param bufnr number Buffer number -function M.accept_theirs(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) - - if not conflict then - vim.notify("No conflict at cursor position", vim.log.levels.WARN) - return - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Extract the "incoming" (AI suggestion) lines - local keep_lines = {} - if conflict.incoming_start and conflict.incoming_end then - for i = conflict.incoming_start, conflict.incoming_end do - table.insert(keep_lines, lines[i]) - end - end - - -- Track where the code will be inserted - local insert_start = conflict.start_line - local insert_end = insert_start + #keep_lines - 1 - - -- Replace the entire conflict region with the kept lines - vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, keep_lines) - - -- Re-process remaining conflicts - M.process(bufnr) - - vim.notify("Accepted INCOMING (AI suggestion) code", vim.log.levels.INFO) - - -- Run linter validation on the accepted code - validate_after_accept(bufnr, insert_start, insert_end, "theirs") - - -- Auto-show menu for next conflict if any remain - auto_show_next_conflict_menu(bufnr) -end - ---- Accept "both" - keep both versions ----@param bufnr number Buffer number -function M.accept_both(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) - - if not conflict then - vim.notify("No conflict at cursor position", vim.log.levels.WARN) - return - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Extract both "current" and "incoming" lines - local keep_lines = {} - - -- Add current lines - if conflict.current_start and conflict.current_end then - for i = conflict.current_start + 1, conflict.current_end do - table.insert(keep_lines, lines[i]) - end - end - - -- Add incoming lines - if conflict.incoming_start and conflict.incoming_end then - for i = conflict.incoming_start, conflict.incoming_end do - table.insert(keep_lines, lines[i]) - end - end - - -- Track where the code will be inserted - local insert_start = conflict.start_line - local insert_end = insert_start + #keep_lines - 1 - - -- Replace the entire conflict region with the kept lines - vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, keep_lines) - - -- Re-process remaining conflicts - M.process(bufnr) - - vim.notify("Accepted BOTH (current + incoming) code", vim.log.levels.INFO) - - -- Run linter validation on the accepted code - validate_after_accept(bufnr, insert_start, insert_end, "both") - - -- Auto-show menu for next conflict if any remain - auto_show_next_conflict_menu(bufnr) -end - ---- Accept "none" - delete both versions ----@param bufnr number Buffer number -function M.accept_none(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) - - if not conflict then - vim.notify("No conflict at cursor position", vim.log.levels.WARN) - return - end - - -- Replace the entire conflict region with nothing - vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, {}) - - -- Re-process remaining conflicts - M.process(bufnr) - - vim.notify("Deleted conflict (accepted NONE)", vim.log.levels.INFO) - - -- Auto-show menu for next conflict if any remain - auto_show_next_conflict_menu(bufnr) -end - ---- Navigate to the next conflict ----@param bufnr number Buffer number ----@return boolean found Whether a conflict was found -function M.goto_next(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local cursor_line = cursor[1] - local conflicts = M.detect_conflicts(bufnr) - - for _, conflict in ipairs(conflicts) do - if conflict.start_line > cursor_line then - vim.api.nvim_win_set_cursor(0, { conflict.start_line, 0 }) - vim.cmd("normal! zz") - return true - end - end - - -- Wrap around to first conflict - if #conflicts > 0 then - vim.api.nvim_win_set_cursor(0, { conflicts[1].start_line, 0 }) - vim.cmd("normal! zz") - vim.notify("Wrapped to first conflict", vim.log.levels.INFO) - return true - end - - vim.notify("No more conflicts", vim.log.levels.INFO) - return false -end - ---- Navigate to the previous conflict ----@param bufnr number Buffer number ----@return boolean found Whether a conflict was found -function M.goto_prev(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local cursor_line = cursor[1] - local conflicts = M.detect_conflicts(bufnr) - - for i = #conflicts, 1, -1 do - local conflict = conflicts[i] - if conflict.start_line < cursor_line then - vim.api.nvim_win_set_cursor(0, { conflict.start_line, 0 }) - vim.cmd("normal! zz") - return true - end - end - - -- Wrap around to last conflict - if #conflicts > 0 then - vim.api.nvim_win_set_cursor(0, { conflicts[#conflicts].start_line, 0 }) - vim.cmd("normal! zz") - vim.notify("Wrapped to last conflict", vim.log.levels.INFO) - return true - end - - vim.notify("No more conflicts", vim.log.levels.INFO) - return false -end - ---- Show conflict resolution menu modal ----@param bufnr number Buffer number -function M.show_menu(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) - - if not conflict then - vim.notify("No conflict at cursor position", vim.log.levels.WARN) - return - end - - -- Get preview of both versions - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - local current_preview = "" - if conflict.current_start and conflict.current_end then - local current_lines = {} - for i = conflict.current_start + 1, math.min(conflict.current_end, conflict.current_start + 3) do - if lines[i] then - table.insert(current_lines, " " .. lines[i]:sub(1, 50)) - end - end - if conflict.current_end - conflict.current_start > 3 then - table.insert(current_lines, " ...") - end - current_preview = table.concat(current_lines, "\n") - end - - local incoming_preview = "" - if conflict.incoming_start and conflict.incoming_end then - local incoming_lines = {} - for i = conflict.incoming_start, math.min(conflict.incoming_end, conflict.incoming_start + 2) do - if lines[i] then - table.insert(incoming_lines, " " .. lines[i]:sub(1, 50)) - end - end - if conflict.incoming_end - conflict.incoming_start > 3 then - table.insert(incoming_lines, " ...") - end - incoming_preview = table.concat(incoming_lines, "\n") - end - - -- Count lines in each section - local current_count = conflict.current_end and conflict.current_start - and (conflict.current_end - conflict.current_start) or 0 - local incoming_count = conflict.incoming_end and conflict.incoming_start - and (conflict.incoming_end - conflict.incoming_start + 1) or 0 - - -- Build menu options - local options = { - { - label = string.format("Accept CURRENT (original) - %d lines", current_count), - key = "co", - action = function() M.accept_ours(bufnr) end, - preview = current_preview, - }, - { - label = string.format("Accept INCOMING (AI suggestion) - %d lines", incoming_count), - key = "ct", - action = function() M.accept_theirs(bufnr) end, - preview = incoming_preview, - }, - { - label = string.format("Accept BOTH versions - %d lines total", current_count + incoming_count), - key = "cb", - action = function() M.accept_both(bufnr) end, - }, - { - label = "Delete conflict (accept NONE)", - key = "cn", - action = function() M.accept_none(bufnr) end, - }, - { - label = "─────────────────────────", - key = "", - action = nil, - separator = true, - }, - { - label = "Next conflict", - key = "]x", - action = function() M.goto_next(bufnr) end, - }, - { - label = "Previous conflict", - key = "[x", - action = function() M.goto_prev(bufnr) end, - }, - } - - -- Build display labels - local labels = {} - for _, opt in ipairs(options) do - if opt.separator then - table.insert(labels, opt.label) - else - table.insert(labels, string.format("[%s] %s", opt.key, opt.label)) - end - end - - -- Show menu using vim.ui.select - vim.ui.select(labels, { - prompt = "Resolve Conflict:", - format_item = function(item) - return item - end, - }, function(choice, idx) - if not choice or not idx then - return - end - - local selected = options[idx] - if selected and selected.action then - selected.action() - end - end) -end - ---- Show floating window menu for conflict resolution ----@param bufnr number Buffer number -function M.show_floating_menu(bufnr) - local cursor = vim.api.nvim_win_get_cursor(0) - local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) - - if not conflict then - vim.notify("No conflict at cursor position", vim.log.levels.WARN) - return - end - - -- Get lines for preview - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Count lines - local current_count = conflict.current_end and conflict.current_start - and (conflict.current_end - conflict.current_start) or 0 - local incoming_count = conflict.incoming_end and conflict.incoming_start - and (conflict.incoming_end - conflict.incoming_start + 1) or 0 - - -- Build menu content - local menu_lines = { - "╭─────────────────────────────────────────╮", - "│ Resolve Conflict │", - "├─────────────────────────────────────────┤", - string.format("│ [co] Accept CURRENT (original) %3d lines│", current_count), - string.format("│ [ct] Accept INCOMING (AI) %3d lines│", incoming_count), - string.format("│ [cb] Accept BOTH %3d lines│", current_count + incoming_count), - "│ [cn] Delete conflict (NONE) │", - "├─────────────────────────────────────────┤", - "│ []x] Next conflict │", - "│ [[x] Previous conflict │", - "│ [q] Close menu │", - "╰─────────────────────────────────────────╯", - } - - -- Create floating window - local width = 43 - local height = #menu_lines - - local float_opts = { - relative = "cursor", - row = 1, - col = 0, - width = width, - height = height, - style = "minimal", - border = "none", - focusable = true, - } - - -- Create buffer for menu - local menu_bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(menu_bufnr, 0, -1, false, menu_lines) - vim.bo[menu_bufnr].modifiable = false - vim.bo[menu_bufnr].bufhidden = "wipe" - - -- Open floating window - local win = vim.api.nvim_open_win(menu_bufnr, true, float_opts) - - -- Set highlights - vim.api.nvim_set_hl(0, "CoderConflictMenuBorder", { fg = "#61afef", default = true }) - vim.api.nvim_set_hl(0, "CoderConflictMenuTitle", { fg = "#e5c07b", bold = true, default = true }) - vim.api.nvim_set_hl(0, "CoderConflictMenuKey", { fg = "#98c379", bold = true, default = true }) - - vim.wo[win].winhl = "Normal:Normal,FloatBorder:CoderConflictMenuBorder" - - -- Add syntax highlighting to menu buffer - vim.api.nvim_buf_add_highlight(menu_bufnr, -1, "CoderConflictMenuTitle", 1, 0, -1) - for i = 3, 9 do - -- Highlight the key in brackets - local line = menu_lines[i + 1] - if line then - local start_col = line:find("%[") - local end_col = line:find("%]") - if start_col and end_col then - vim.api.nvim_buf_add_highlight(menu_bufnr, -1, "CoderConflictMenuKey", i, start_col - 1, end_col) - end - end - end - - -- Setup keymaps for the menu - local close_menu = function() - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - end - - -- Use nowait to prevent delay from built-in 'c' command - local menu_opts = { buffer = menu_bufnr, silent = true, noremap = true, nowait = true } - - vim.keymap.set("n", "q", close_menu, menu_opts) - vim.keymap.set("n", "", close_menu, menu_opts) - - vim.keymap.set("n", "co", function() - close_menu() - M.accept_ours(bufnr) - end, menu_opts) - - vim.keymap.set("n", "ct", function() - close_menu() - M.accept_theirs(bufnr) - end, menu_opts) - - vim.keymap.set("n", "cb", function() - close_menu() - M.accept_both(bufnr) - end, menu_opts) - - vim.keymap.set("n", "cn", function() - close_menu() - M.accept_none(bufnr) - end, menu_opts) - - vim.keymap.set("n", "]x", function() - close_menu() - M.goto_next(bufnr) - end, menu_opts) - - vim.keymap.set("n", "[x", function() - close_menu() - M.goto_prev(bufnr) - end, menu_opts) - - -- Also support number keys for quick selection - vim.keymap.set("n", "1", function() - close_menu() - M.accept_ours(bufnr) - end, menu_opts) - - vim.keymap.set("n", "2", function() - close_menu() - M.accept_theirs(bufnr) - end, menu_opts) - - vim.keymap.set("n", "3", function() - close_menu() - M.accept_both(bufnr) - end, menu_opts) - - vim.keymap.set("n", "4", function() - close_menu() - M.accept_none(bufnr) - end, menu_opts) - - -- Close on focus lost - vim.api.nvim_create_autocmd("WinLeave", { - buffer = menu_bufnr, - once = true, - callback = close_menu, - }) -end - ---- Setup keybindings for conflict resolution in a buffer ----@param bufnr number Buffer number -function M.setup_keymaps(bufnr) - -- Use nowait to prevent delay from built-in 'c' command - local opts = { buffer = bufnr, silent = true, noremap = true, nowait = true } - - -- Accept ours (original) - vim.keymap.set("n", "co", function() - M.accept_ours(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Accept CURRENT (original) code" })) - - -- Accept theirs (AI suggestion) - vim.keymap.set("n", "ct", function() - M.accept_theirs(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Accept INCOMING (AI suggestion) code" })) - - -- Accept both - vim.keymap.set("n", "cb", function() - M.accept_both(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Accept BOTH versions" })) - - -- Accept none - vim.keymap.set("n", "cn", function() - M.accept_none(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Delete conflict (accept NONE)" })) - - -- Navigate to next conflict - vim.keymap.set("n", "]x", function() - M.goto_next(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Go to next conflict" })) - - -- Navigate to previous conflict - vim.keymap.set("n", "[x", function() - M.goto_prev(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Go to previous conflict" })) - - -- Show menu modal - vim.keymap.set("n", "cm", function() - M.show_floating_menu(bufnr) - end, vim.tbl_extend("force", opts, { desc = "Show conflict resolution menu" })) - - -- Also map to show menu when on conflict - vim.keymap.set("n", "", function() - local cursor = vim.api.nvim_win_get_cursor(0) - if M.get_conflict_at_cursor(bufnr, cursor[1]) then - M.show_floating_menu(bufnr) - else - -- Default behavior - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false) - end - end, vim.tbl_extend("force", opts, { desc = "Show conflict menu or default action" })) - - -- Mark buffer as having conflict keymaps - conflict_buffers[bufnr] = { - keymaps_set = true, - } -end - ---- Remove keybindings from a buffer ----@param bufnr number Buffer number -function M.remove_keymaps(bufnr) - if not conflict_buffers[bufnr] then - return - end - - pcall(vim.keymap.del, "n", "co", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "ct", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "cb", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "cn", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "cm", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "]x", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "[x", { buffer = bufnr }) - pcall(vim.keymap.del, "n", "", { buffer = bufnr }) - - conflict_buffers[bufnr] = nil -end - ---- Insert conflict markers for a code change ----@param bufnr number Buffer number ----@param start_line number Start line (1-indexed) ----@param end_line number End line (1-indexed) ----@param new_lines string[] New lines to insert as "incoming" ----@param label? string Optional label for the incoming section -function M.insert_conflict(bufnr, start_line, end_line, new_lines, label) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Clamp to valid range - local line_count = #lines - start_line = math.max(1, math.min(start_line, line_count + 1)) - end_line = math.max(start_line, math.min(end_line, line_count)) - - -- Extract current lines - local current_lines = {} - for i = start_line, end_line do - if lines[i] then - table.insert(current_lines, lines[i]) - end - end - - -- Build conflict block - local conflict_block = {} - table.insert(conflict_block, MARKERS.current_start) - for _, line in ipairs(current_lines) do - table.insert(conflict_block, line) - end - table.insert(conflict_block, MARKERS.separator) - for _, line in ipairs(new_lines) do - table.insert(conflict_block, line) - end - table.insert(conflict_block, label and (">>>>>>> " .. label) or MARKERS.incoming_end) - - -- Replace the range with conflict block - vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, conflict_block) -end - ---- Process buffer and auto-show menu for first conflict ---- Call this after inserting conflict(s) to set up highlights and show menu ----@param bufnr number Buffer number -function M.process_and_show_menu(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - -- Process to set up highlights and keymaps - local conflict_count = M.process(bufnr) - - -- Auto-show menu if enabled and conflicts exist - if config.auto_show_menu and conflict_count > 0 then - vim.schedule(function() - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - -- Find window showing this buffer and focus it - local win = nil - for _, w in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_get_buf(w) == bufnr then - win = w - break - end - end - - if win then - vim.api.nvim_set_current_win(win) - -- Jump to first conflict - local conflicts = M.detect_conflicts(bufnr) - if #conflicts > 0 then - vim.api.nvim_win_set_cursor(win, { conflicts[1].start_line, 0 }) - vim.cmd("normal! zz") - -- Show the menu - M.show_floating_menu(bufnr) - end - end - end) - end -end - ---- Process a buffer for conflicts - detect, highlight, and setup keymaps ----@param bufnr number Buffer number ----@return number conflict_count Number of conflicts found -function M.process(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return 0 - end - - -- Setup highlights if not done - setup_highlights() - - -- Detect conflicts - local conflicts = M.detect_conflicts(bufnr) - - if #conflicts > 0 then - -- Highlight conflicts - M.highlight_conflicts(bufnr, conflicts) - - -- Setup keymaps if not already done - if not conflict_buffers[bufnr] then - M.setup_keymaps(bufnr) - end - - -- Log - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.info(string.format("Found %d conflict(s) - use co/ct/cb/cn to resolve, [x/]x to navigate", #conflicts)) - end) - else - -- No conflicts - clean up - vim.api.nvim_buf_clear_namespace(bufnr, NAMESPACE, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, HINT_NAMESPACE, 0, -1) - M.remove_keymaps(bufnr) - end - - return #conflicts -end - ---- Check if a buffer has conflicts ----@param bufnr number Buffer number ----@return boolean -function M.has_conflicts(bufnr) - local conflicts = M.detect_conflicts(bufnr) - return #conflicts > 0 -end - ---- Get conflict count for a buffer ----@param bufnr number Buffer number ----@return number -function M.count_conflicts(bufnr) - local conflicts = M.detect_conflicts(bufnr) - return #conflicts -end - ---- Clear all conflicts from a buffer (remove markers but keep current code) ----@param bufnr number Buffer number ----@param keep "ours"|"theirs"|"both"|"none" Which version to keep -function M.resolve_all(bufnr, keep) - local conflicts = M.detect_conflicts(bufnr) - - -- Process in reverse order to maintain line numbers - for i = #conflicts, 1, -1 do - -- Move cursor to conflict - vim.api.nvim_win_set_cursor(0, { conflicts[i].start_line, 0 }) - - -- Accept based on preference - if keep == "ours" then - M.accept_ours(bufnr) - elseif keep == "theirs" then - M.accept_theirs(bufnr) - elseif keep == "both" then - M.accept_both(bufnr) - else - M.accept_none(bufnr) - end - end -end - ---- Add a buffer to conflict tracking (for auto-follow) ----@param bufnr number Buffer number -function M.add_tracked_buffer(bufnr) - if not conflict_buffers[bufnr] then - conflict_buffers[bufnr] = {} - end -end - ---- Get all tracked buffers with conflicts ----@return number[] buffers List of buffer numbers -function M.get_tracked_buffers() - local buffers = {} - for bufnr, _ in pairs(conflict_buffers) do - if vim.api.nvim_buf_is_valid(bufnr) and M.has_conflicts(bufnr) then - table.insert(buffers, bufnr) - end - end - return buffers -end - ---- Clear tracking for a buffer ----@param bufnr number Buffer number -function M.clear_buffer(bufnr) - vim.api.nvim_buf_clear_namespace(bufnr, NAMESPACE, 0, -1) - vim.api.nvim_buf_clear_namespace(bufnr, HINT_NAMESPACE, 0, -1) - M.remove_keymaps(bufnr) - conflict_buffers[bufnr] = nil -end - ---- Initialize the conflict module -function M.setup() - setup_highlights() - - -- Auto-clean up when buffers are deleted - vim.api.nvim_create_autocmd("BufDelete", { - group = vim.api.nvim_create_augroup("CoderConflict", { clear = true }), - callback = function(ev) - conflict_buffers[ev.buf] = nil - end, - }) -end - -return M diff --git a/lua/codetyper/core/diff/diff.lua b/lua/codetyper/core/diff/diff.lua deleted file mode 100644 index 53c0ea1..0000000 --- a/lua/codetyper/core/diff/diff.lua +++ /dev/null @@ -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", "", 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", "", function() - close_and_respond(false) - end, vim.tbl_extend("force", keymap_opts, { buffer = buf })) - - -- Switch between windows - vim.keymap.set("n", "", 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", "", 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", "", 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 diff --git a/lua/codetyper/core/diff/patch.lua b/lua/codetyper/core/diff/patch.lua deleted file mode 100644 index 232a6dd..0000000 --- a/lua/codetyper/core/diff/patch.lua +++ /dev/null @@ -1,1098 +0,0 @@ ----@mod codetyper.agent.patch Patch system with staleness detection ----@brief [[ ---- Manages code patches with buffer snapshots for staleness detection. ---- Patches are queued for safe injection when completion popup is not visible. ---- Uses SEARCH/REPLACE blocks for reliable code editing. ----@brief ]] - -local M = {} - -local params = require("codetyper.params.agents.patch") - - ---- Lazy load inject module to avoid circular requires -local function get_inject_module() - return require("codetyper.inject") -end - ---- Lazy load search_replace module -local function get_search_replace_module() - return require("codetyper.core.diff.search_replace") -end - ---- Lazy load conflict module -local function get_conflict_module() - return require("codetyper.core.diff.conflict") -end - ---- Configuration for patch behavior -local config = params.config - ----@class BufferSnapshot ----@field bufnr number Buffer number ----@field changedtick number vim.b.changedtick at snapshot time ----@field content_hash string Hash of buffer content in range ----@field range {start_line: number, end_line: number}|nil Range snapshotted - ----@class PatchCandidate ----@field id string Unique patch ID ----@field event_id string Related PromptEvent ID ----@field source_bufnr number Source buffer where prompt tags are (coder file) ----@field target_bufnr number Target buffer for injection (real file) ----@field target_path string Target file path ----@field original_snapshot BufferSnapshot Snapshot at event creation ----@field generated_code string Code to inject ----@field injection_range {start_line: number, end_line: number}|nil ----@field injection_strategy string "append"|"replace"|"insert"|"search_replace" ----@field confidence number Confidence score (0.0-1.0) ----@field status string "pending"|"applied"|"stale"|"rejected" ----@field created_at number Timestamp ----@field applied_at number|nil When applied ----@field use_search_replace boolean Whether to use SEARCH/REPLACE block parsing ----@field search_replace_blocks table[]|nil Parsed SEARCH/REPLACE blocks - ---- Patch storage ----@type PatchCandidate[] -local patches = {} - ---- Patch ID counter -local patch_counter = 0 - ---- Generate unique patch ID ----@return string -function M.generate_id() - patch_counter = patch_counter + 1 - return string.format("patch_%d_%d", os.time(), patch_counter) -end - ---- Hash buffer content in range ----@param bufnr number ----@param start_line number|nil 1-indexed, nil for whole buffer ----@param end_line number|nil 1-indexed, nil for whole buffer ----@return string -local function hash_buffer_range(bufnr, start_line, end_line) - if not vim.api.nvim_buf_is_valid(bufnr) then - return "" - end - - local lines - if start_line and end_line then - lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) - else - lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - end - - local content = table.concat(lines, "\n") - local hash = 0 - for i = 1, #content do - hash = (hash * 31 + string.byte(content, i)) % 2147483647 - end - return string.format("%x", hash) -end - ---- Take a snapshot of buffer state ----@param bufnr number Buffer number ----@param range {start_line: number, end_line: number}|nil Optional range ----@return BufferSnapshot -function M.snapshot_buffer(bufnr, range) - local changedtick = 0 - if vim.api.nvim_buf_is_valid(bufnr) then - changedtick = vim.api.nvim_buf_get_var(bufnr, "changedtick") or vim.b[bufnr].changedtick or 0 - end - - local content_hash - if range then - content_hash = hash_buffer_range(bufnr, range.start_line, range.end_line) - else - content_hash = hash_buffer_range(bufnr, nil, nil) - end - - return { - bufnr = bufnr, - changedtick = changedtick, - content_hash = content_hash, - range = range, - } -end - ---- Check if buffer changed since snapshot ----@param snapshot BufferSnapshot ----@return boolean is_stale ----@return string|nil reason -function M.is_snapshot_stale(snapshot) - if not vim.api.nvim_buf_is_valid(snapshot.bufnr) then - return true, "buffer_invalid" - end - - -- Check changedtick first (fast path) - local current_tick = vim.api.nvim_buf_get_var(snapshot.bufnr, "changedtick") - or vim.b[snapshot.bufnr].changedtick or 0 - - if current_tick ~= snapshot.changedtick then - -- Changedtick differs, but might be just cursor movement - -- Verify with content hash - local current_hash - if snapshot.range then - current_hash = hash_buffer_range( - snapshot.bufnr, - snapshot.range.start_line, - snapshot.range.end_line - ) - else - current_hash = hash_buffer_range(snapshot.bufnr, nil, nil) - end - - if current_hash ~= snapshot.content_hash then - return true, "content_changed" - end - end - - return false, nil -end - ---- Check if a patch is stale ----@param patch PatchCandidate ----@return boolean ----@return string|nil reason -function M.is_stale(patch) - return M.is_snapshot_stale(patch.original_snapshot) -end - ---- Queue a patch for deferred application ----@param patch PatchCandidate ----@return PatchCandidate -function M.queue_patch(patch) - patch.id = patch.id or M.generate_id() - patch.status = patch.status or "pending" - patch.created_at = patch.created_at or os.time() - - table.insert(patches, patch) - - -- Log patch creation - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "patch", - message = string.format( - "Patch queued: %s (confidence: %.2f)", - patch.id, patch.confidence or 0 - ), - data = { - patch_id = patch.id, - event_id = patch.event_id, - target_path = patch.target_path, - code_preview = patch.generated_code:sub(1, 50), - }, - }) - end) - - return patch -end - ---- Create patch from event and response ----@param event table PromptEvent ----@param generated_code string ----@param confidence number ----@param strategy string|nil Injection strategy (overrides intent-based) ----@return PatchCandidate -function M.create_from_event(event, generated_code, confidence, strategy) - -- Source buffer is where the prompt tags are (could be coder file) - local source_bufnr = event.bufnr - - -- Get target buffer (where code should be injected - the real file) - local target_bufnr = vim.fn.bufnr(event.target_path) - if target_bufnr == -1 then - -- Try to find by filename - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - local name = vim.api.nvim_buf_get_name(buf) - if name == event.target_path then - target_bufnr = buf - break - end - end - end - - -- Detect if this is an inline prompt (source == target, not a .coder. file) - local is_inline = (source_bufnr == target_bufnr) or - (event.target_path and not event.target_path:match("%.coder%.")) - - -- Take snapshot of the scope range in target buffer (for staleness detection) - local snapshot_range = event.scope_range or event.range - local snapshot = M.snapshot_buffer( - target_bufnr ~= -1 and target_bufnr or event.bufnr, - snapshot_range - ) - - -- Check if the response contains SEARCH/REPLACE blocks - local search_replace = get_search_replace_module() - local sr_blocks = search_replace.parse_blocks(generated_code) - local use_search_replace = #sr_blocks > 0 - - -- Determine injection strategy and range based on intent - local injection_strategy = strategy - local injection_range = nil - - -- If we have SEARCH/REPLACE blocks, use that strategy - if use_search_replace then - injection_strategy = "search_replace" - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format("Using SEARCH/REPLACE mode with %d block(s)", #sr_blocks), - }) - end) - elseif not injection_strategy and event.intent then - local intent_mod = require("codetyper.core.intent") - if intent_mod.is_replacement(event.intent) then - injection_strategy = "replace" - - -- INLINE PROMPTS: Always use tag range - -- The LLM is told specifically to replace the tagged region - if is_inline and event.range then - injection_range = { - start_line = event.range.start_line, - end_line = event.range.end_line, - } - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format("Inline prompt: will replace tag region (lines %d-%d)", - event.range.start_line, event.range.end_line), - }) - end) - -- CODER FILES: Use scope range for replacement - elseif event.scope_range then - injection_range = event.scope_range - else - -- Fallback: no scope found (treesitter didn't find function) - -- Use tag range - the generated code will replace the tag region - injection_range = { - start_line = event.range.start_line, - end_line = event.range.end_line, - } - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "warning", - message = "No scope found, using tag range as fallback", - }) - end) - end - elseif event.intent.action == "insert" then - injection_strategy = "insert" - -- Insert at prompt location (use full tag range) - injection_range = { start_line = event.range.start_line, end_line = event.range.end_line } - elseif event.intent.action == "append" then - injection_strategy = "append" - -- Will append to end of file - else - injection_strategy = "append" - end - end - - injection_strategy = injection_strategy or "append" - - return { - id = M.generate_id(), - event_id = event.id, - source_bufnr = source_bufnr, -- Where prompt tags are (coder file) - target_bufnr = target_bufnr, -- Where code goes (real file) - target_path = event.target_path, - original_snapshot = snapshot, - generated_code = generated_code, - injection_range = injection_range, - injection_strategy = injection_strategy, - confidence = confidence, - status = "pending", - created_at = os.time(), - intent = event.intent, - scope = event.scope, - -- Store the prompt tag range so we can delete it after applying - prompt_tag_range = event.range, - -- Mark if this is an inline prompt (tags in source file, not coder file) - is_inline_prompt = is_inline, - -- SEARCH/REPLACE support - use_search_replace = use_search_replace, - search_replace_blocks = use_search_replace and sr_blocks or nil, - } -end - ---- Get all pending patches ----@return PatchCandidate[] -function M.get_pending() - local pending = {} - for _, patch in ipairs(patches) do - if patch.status == "pending" then - table.insert(pending, patch) - end - end - return pending -end - ---- Get patch by ID ----@param id string ----@return PatchCandidate|nil -function M.get(id) - for _, patch in ipairs(patches) do - if patch.id == id then - return patch - end - end - return nil -end - ---- Get patches for event ----@param event_id string ----@return PatchCandidate[] -function M.get_for_event(event_id) - local result = {} - for _, patch in ipairs(patches) do - if patch.event_id == event_id then - table.insert(result, patch) - end - end - return result -end - ---- Mark patch as applied ----@param id string ----@return boolean -function M.mark_applied(id) - local patch = M.get(id) - if patch then - patch.status = "applied" - patch.applied_at = os.time() - return true - end - return false -end - ---- Mark patch as stale ----@param id string ----@param reason string|nil ----@return boolean -function M.mark_stale(id, reason) - local patch = M.get(id) - if patch then - patch.status = "stale" - patch.stale_reason = reason - return true - end - return false -end - ---- Mark patch as rejected ----@param id string ----@param reason string|nil ----@return boolean -function M.mark_rejected(id, reason) - local patch = M.get(id) - if patch then - patch.status = "rejected" - patch.reject_reason = reason - return true - end - return false -end - ---- Remove /@ @/ prompt tags from buffer ----@param bufnr number Buffer number ----@return number Number of tag regions removed -local function remove_prompt_tags(bufnr) - if not vim.api.nvim_buf_is_valid(bufnr) then - return 0 - end - - local removed = 0 - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Find and remove all /@ ... @/ regions (can be multiline) - local i = 1 - while i <= #lines do - local line = lines[i] - local open_start = line:find("/@") - - if open_start then - -- Found an opening tag, look for closing tag - local close_end = nil - local close_line = i - - -- Check if closing tag is on same line - local after_open = line:sub(open_start + 2) - local same_line_close = after_open:find("@/") - if same_line_close then - -- Single line tag - remove just this portion - local before = line:sub(1, open_start - 1) - local after = line:sub(open_start + 2 + same_line_close + 1) - lines[i] = before .. after - -- If line is now empty or just whitespace, remove it - if lines[i]:match("^%s*$") then - table.remove(lines, i) - else - i = i + 1 - end - removed = removed + 1 - else - -- Multi-line tag - find the closing line - for j = i, #lines do - if lines[j]:find("@/") then - close_line = j - close_end = lines[j]:find("@/") - break - end - end - - if close_end then - -- Remove lines from i to close_line - -- Keep content before /@ on first line and after @/ on last line - local before = lines[i]:sub(1, open_start - 1) - local after = lines[close_line]:sub(close_end + 2) - - -- Remove the lines containing the tag - for _ = i, close_line do - table.remove(lines, i) - end - - -- If there's content to keep, insert it back - local remaining = (before .. after):match("^%s*(.-)%s*$") - if remaining and remaining ~= "" then - table.insert(lines, i, remaining) - i = i + 1 - end - - removed = removed + 1 - else - -- No closing tag found, skip this line - i = i + 1 - end - end - else - i = i + 1 - end - end - - if removed > 0 then - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - end - - return removed -end - ---- Check if it's safe to modify the buffer (not in insert mode) ----@return boolean -local function is_safe_to_modify() - local mode = vim.fn.mode() - -- Don't modify if in insert mode or completion is visible - if mode == "i" or mode == "ic" or mode == "ix" then - return false - end - if vim.fn.pumvisible() == 1 then - return false - end - return true -end - ---- Apply a patch to the target buffer ----@param patch PatchCandidate ----@return boolean success ----@return string|nil error -function M.apply(patch) - -- Check if safe to modify (not in insert mode) - if not is_safe_to_modify() then - return false, "user_typing" - end - - -- Check staleness first - local is_stale, stale_reason = M.is_stale(patch) - if is_stale then - M.mark_stale(patch.id, stale_reason) - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "warning", - message = string.format("Patch %s is stale: %s", patch.id, stale_reason or "unknown"), - }) - end) - - return false, "patch_stale: " .. (stale_reason or "unknown") - end - - -- Ensure target buffer is valid - local target_bufnr = patch.target_bufnr - if target_bufnr == -1 or not vim.api.nvim_buf_is_valid(target_bufnr) then - -- Try to load buffer from path - target_bufnr = vim.fn.bufadd(patch.target_path) - if target_bufnr == 0 then - M.mark_rejected(patch.id, "buffer_not_found") - return false, "target buffer not found" - end - vim.fn.bufload(target_bufnr) - patch.target_bufnr = target_bufnr - end - - -- Prepare code lines - local code_lines = vim.split(patch.generated_code, "\n", { plain = true }) - - -- Use the stored inline prompt flag (computed during patch creation) - -- For inline prompts, we replace the tag region directly instead of separate remove + inject - local source_bufnr = patch.source_bufnr - local is_inline_prompt = patch.is_inline_prompt or (source_bufnr == target_bufnr) - local tags_removed = 0 - - -- For CODER FILES (source != target): Remove tags from source, inject into target - -- For INLINE PROMPTS (source == target): Include tag range in injection, no separate removal - if not is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then - tags_removed = remove_prompt_tags(source_bufnr) - - pcall(function() - if tags_removed > 0 then - local logs = require("codetyper.adapters.nvim.ui.logs") - local source_name = vim.api.nvim_buf_get_name(source_bufnr) - logs.add({ - type = "info", - message = string.format("Removed %d prompt tag(s) from %s", - tags_removed, - vim.fn.fnamemodify(source_name, ":t")), - }) - end - end) - end - - -- Get filetype for smart injection - local filetype = vim.fn.fnamemodify(patch.target_path or "", ":e") - - -- SEARCH/REPLACE MODE: Use fuzzy matching to find and replace text - if patch.use_search_replace and patch.search_replace_blocks and #patch.search_replace_blocks > 0 then - local search_replace = get_search_replace_module() - - -- Remove the /@ @/ tags first (they shouldn't be in the file anymore) - if is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then - tags_removed = remove_prompt_tags(source_bufnr) - if tags_removed > 0 then - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format("Removed %d prompt tag(s)", tags_removed), - }) - end) - end - end - - -- Apply SEARCH/REPLACE blocks - local success, err = search_replace.apply_to_buffer(target_bufnr, patch.search_replace_blocks) - - if success then - M.mark_applied(patch.id) - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "success", - message = string.format("Patch %s applied via SEARCH/REPLACE (%d block(s))", - patch.id, #patch.search_replace_blocks), - data = { - target_path = patch.target_path, - blocks_applied = #patch.search_replace_blocks, - }, - }) - end) - - -- Learn from successful code generation - pcall(function() - local brain = require("codetyper.core.memory") - if brain.is_initialized() then - local intent_type = patch.intent and patch.intent.type or "unknown" - brain.learn({ - type = "code_completion", - file = patch.target_path, - timestamp = os.time(), - data = { - intent = intent_type, - method = "search_replace", - language = filetype, - confidence = patch.confidence or 0.5, - }, - }) - end - end) - - return true, nil - else - -- SEARCH/REPLACE failed, log the error - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "warning", - message = string.format("SEARCH/REPLACE failed: %s. Falling back to line-based injection.", err or "unknown"), - }) - end) - -- Fall through to line-based injection as fallback - end - end - - -- Use smart injection module for intelligent import handling - local inject = get_inject_module() - local inject_result = nil - - -- Apply based on strategy using smart injection - local ok, err = pcall(function() - -- Prepare injection options - local inject_opts = { - strategy = patch.injection_strategy, - filetype = filetype, - sort_imports = true, - } - - if patch.injection_strategy == "replace" and patch.injection_range then - -- Replace the scope range with the new code - local start_line = patch.injection_range.start_line - local end_line = patch.injection_range.end_line - - -- For inline prompts, use scope range directly (tags are inside scope) - -- No adjustment needed since we didn't remove tags yet - if not is_inline_prompt and patch.scope and patch.scope.type then - -- For coder files, tags were already removed, so we may need to find the scope again - local found_range = nil - pcall(function() - local parsers = require("nvim-treesitter.parsers") - local parser = parsers.get_parser(target_bufnr) - if parser then - local tree = parser:parse()[1] - if tree then - local root = tree:root() - -- Find the function/method node that contains our original position - local function find_scope_node(node) - local node_type = node:type() - local is_scope = node_type:match("function") - or node_type:match("method") - or node_type:match("class") - or node_type:match("declaration") - - if is_scope then - local s_row, _, e_row, _ = node:range() - -- Check if this scope roughly matches our expected range - if math.abs(s_row - (start_line - 1)) <= 5 then - found_range = { start_line = s_row + 1, end_line = e_row + 1 } - return true - end - end - - for child in node:iter_children() do - if find_scope_node(child) then - return true - end - end - return false - end - find_scope_node(root) - end - end - end) - - if found_range then - start_line = found_range.start_line - end_line = found_range.end_line - end - end - - -- Clamp to valid range - local line_count = vim.api.nvim_buf_line_count(target_bufnr) - start_line = math.max(1, start_line) - end_line = math.min(line_count, end_line) - - inject_opts.range = { start_line = start_line, end_line = end_line } - elseif patch.injection_strategy == "insert" and patch.injection_range then - -- For inline prompts with "insert" strategy, replace the TAG RANGE - -- (the tag itself gets replaced with the new code) - if is_inline_prompt and patch.prompt_tag_range then - inject_opts.range = { - start_line = patch.prompt_tag_range.start_line, - end_line = patch.prompt_tag_range.end_line - } - -- Switch to replace strategy for the tag range - inject_opts.strategy = "replace" - else - inject_opts.range = { start_line = patch.injection_range.start_line } - end - end - - -- Log inline prompt handling - if is_inline_prompt then - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format("Inline prompt: replacing lines %d-%d", - inject_opts.range and inject_opts.range.start_line or 0, - inject_opts.range and inject_opts.range.end_line or 0), - }) - end) - end - - -- Use smart injection - handles imports automatically - inject_result = inject.inject(target_bufnr, patch.generated_code, inject_opts) - - -- Log injection details - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - if inject_result.imports_added > 0 then - logs.add({ - type = "info", - message = string.format( - "%s %d import(s), injected %d body line(s)", - inject_result.imports_merged and "Merged" or "Added", - inject_result.imports_added, - inject_result.body_lines - ), - }) - else - logs.add({ - type = "info", - message = string.format("Injected %d line(s) of code", inject_result.body_lines), - }) - end - end) - end) - - if not ok then - M.mark_rejected(patch.id, err) - return false, err - end - - M.mark_applied(patch.id) - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "success", - message = string.format("Patch %s applied successfully", patch.id), - data = { - target_path = patch.target_path, - lines_added = #code_lines, - }, - }) - end) - - -- Learn from successful code generation - this builds neural pathways - -- The more code is successfully applied, the better the brain becomes - pcall(function() - local brain = require("codetyper.core.memory") - if brain.is_initialized() then - -- Learn the successful pattern - local intent_type = patch.intent and patch.intent.type or "unknown" - local scope_type = patch.scope and patch.scope.type or "file" - local scope_name = patch.scope and patch.scope.name or "" - - -- Create a meaningful summary for this learning - local summary = string.format( - "Generated %s: %s %s in %s", - intent_type, - scope_type, - scope_name ~= "" and scope_name or "", - vim.fn.fnamemodify(patch.target_path or "", ":t") - ) - - brain.learn({ - type = "code_completion", - file = patch.target_path, - timestamp = os.time(), - data = { - intent = intent_type, - code = patch.generated_code:sub(1, 500), -- Store first 500 chars - language = vim.fn.fnamemodify(patch.target_path or "", ":e"), - function_name = scope_name, - prompt = patch.prompt_content, - confidence = patch.confidence or 0.5, - }, - }) - end - end) - - return true, nil -end - ---- Flush all pending patches that are safe to apply ----@return number applied_count ----@return number stale_count ----@return number deferred_count -function M.flush_pending() - local applied = 0 - local stale = 0 - local deferred = 0 - - for _, p in ipairs(patches) do - if p.status == "pending" then - local success, err = M.apply(p) - if success then - applied = applied + 1 - elseif err == "user_typing" then - -- Keep pending, will retry later - deferred = deferred + 1 - else - stale = stale + 1 - end - end - end - - return applied, stale, deferred -end - ---- Cancel all pending patches for a buffer ----@param bufnr number ----@return number cancelled_count -function M.cancel_for_buffer(bufnr) - local cancelled = 0 - for _, patch in ipairs(patches) do - if patch.status == "pending" and - (patch.target_bufnr == bufnr or patch.original_snapshot.bufnr == bufnr) then - patch.status = "cancelled" - cancelled = cancelled + 1 - end - end - return cancelled -end - ---- Cleanup old patches ----@param max_age number Max age in seconds (default: 300) -function M.cleanup(max_age) - max_age = max_age or 300 - local now = os.time() - local i = 1 - while i <= #patches do - local patch = patches[i] - if patch.status ~= "pending" and (now - patch.created_at) > max_age then - table.remove(patches, i) - else - i = i + 1 - end - end -end - ---- Get statistics ----@return table -function M.stats() - local stats = { - total = #patches, - pending = 0, - applied = 0, - stale = 0, - rejected = 0, - cancelled = 0, - } - for _, patch in ipairs(patches) do - local s = patch.status - if stats[s] then - stats[s] = stats[s] + 1 - end - end - return stats -end - ---- Clear all patches -function M.clear() - patches = {} -end - ---- Configure patch behavior ----@param opts table Configuration options ---- - use_conflict_mode: boolean Use conflict markers instead of direct apply ---- - auto_jump_to_conflict: boolean Auto-jump to first conflict after applying -function M.configure(opts) - if opts.use_conflict_mode ~= nil then - config.use_conflict_mode = opts.use_conflict_mode - end - if opts.auto_jump_to_conflict ~= nil then - config.auto_jump_to_conflict = opts.auto_jump_to_conflict - end -end - ---- Get current configuration ----@return table -function M.get_config() - return vim.deepcopy(config) -end - ---- Check if conflict mode is enabled ----@return boolean -function M.is_conflict_mode() - return config.use_conflict_mode -end - ---- Apply a patch using conflict markers for interactive review ---- Instead of directly replacing code, inserts git-style conflict markers ----@param patch PatchCandidate ----@return boolean success ----@return string|nil error -function M.apply_with_conflict(patch) - -- Check if safe to modify (not in insert mode) - if not is_safe_to_modify() then - return false, "user_typing" - end - - -- Check staleness first - local is_stale, stale_reason = M.is_stale(patch) - if is_stale then - M.mark_stale(patch.id, stale_reason) - return false, "patch_stale: " .. (stale_reason or "unknown") - end - - -- Ensure target buffer is valid - local target_bufnr = patch.target_bufnr - if target_bufnr == -1 or not vim.api.nvim_buf_is_valid(target_bufnr) then - target_bufnr = vim.fn.bufadd(patch.target_path) - if target_bufnr == 0 then - M.mark_rejected(patch.id, "buffer_not_found") - return false, "target buffer not found" - end - vim.fn.bufload(target_bufnr) - patch.target_bufnr = target_bufnr - end - - local conflict = get_conflict_module() - local source_bufnr = patch.source_bufnr - local is_inline_prompt = patch.is_inline_prompt or (source_bufnr == target_bufnr) - - -- Remove tags from coder files - if not is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then - remove_prompt_tags(source_bufnr) - end - - -- For SEARCH/REPLACE blocks, convert each block to a conflict - if patch.use_search_replace and patch.search_replace_blocks and #patch.search_replace_blocks > 0 then - local search_replace = get_search_replace_module() - local content = table.concat(vim.api.nvim_buf_get_lines(target_bufnr, 0, -1, false), "\n") - local applied_count = 0 - - -- Sort blocks by position (bottom to top) to maintain line numbers - local sorted_blocks = {} - for _, block in ipairs(patch.search_replace_blocks) do - local match = search_replace.find_match(content, block.search) - if match then - block._match = match - table.insert(sorted_blocks, block) - end - end - table.sort(sorted_blocks, function(a, b) - return (a._match and a._match.start_line or 0) > (b._match and b._match.start_line or 0) - end) - - -- Apply each block as a conflict - for _, block in ipairs(sorted_blocks) do - local match = block._match - if match then - local new_lines = vim.split(block.replace, "\n", { plain = true }) - conflict.insert_conflict( - target_bufnr, - match.start_line, - match.end_line, - new_lines, - "AI SUGGESTION" - ) - applied_count = applied_count + 1 - -- Re-read content for next match (line numbers changed) - content = table.concat(vim.api.nvim_buf_get_lines(target_bufnr, 0, -1, false), "\n") - end - end - - if applied_count > 0 then - -- Remove tags for inline prompts after inserting conflicts - if is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then - remove_prompt_tags(source_bufnr) - end - - -- Process conflicts (highlight, keymaps) and show menu - conflict.process_and_show_menu(target_bufnr) - - M.mark_applied(patch.id) - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "success", - message = string.format( - "Created %d conflict(s) for review - use co/ct/cb/cn to resolve", - applied_count - ), - }) - end) - - return true, nil - end - end - - -- Fallback: Use injection range if available - if patch.injection_range then - local start_line = patch.injection_range.start_line - local end_line = patch.injection_range.end_line - local new_lines = vim.split(patch.generated_code, "\n", { plain = true }) - - -- Remove tags for inline prompts - if is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then - remove_prompt_tags(source_bufnr) - end - - -- Insert conflict markers - conflict.insert_conflict(target_bufnr, start_line, end_line, new_lines, "AI SUGGESTION") - - -- Process conflicts (highlight, keymaps) and show menu - conflict.process_and_show_menu(target_bufnr) - - M.mark_applied(patch.id) - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "success", - message = "Created conflict for review - use co/ct/cb/cn to resolve", - }) - end) - - return true, nil - end - - -- No suitable range found, fall back to direct apply - return M.apply(patch) -end - ---- Smart apply - uses conflict mode if enabled, otherwise direct apply ----@param patch PatchCandidate ----@return boolean success ----@return string|nil error -function M.smart_apply(patch) - if config.use_conflict_mode then - return M.apply_with_conflict(patch) - else - return M.apply(patch) - end -end - ---- Flush all pending patches using smart apply ----@return number applied_count ----@return number stale_count ----@return number deferred_count -function M.flush_pending_smart() - local applied = 0 - local stale = 0 - local deferred = 0 - - for _, p in ipairs(patches) do - if p.status == "pending" then - local success, err = M.smart_apply(p) - if success then - applied = applied + 1 - elseif err == "user_typing" then - deferred = deferred + 1 - else - stale = stale + 1 - end - end - end - - return applied, stale, deferred -end - -return M diff --git a/lua/codetyper/core/diff/search_replace.lua b/lua/codetyper/core/diff/search_replace.lua deleted file mode 100644 index 0fc6eb9..0000000 --- a/lua/codetyper/core/diff/search_replace.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/intent/init.lua b/lua/codetyper/core/intent/init.lua deleted file mode 100644 index 169e5c9..0000000 --- a/lua/codetyper/core/intent/init.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/llm/confidence.lua b/lua/codetyper/core/llm/confidence.lua deleted file mode 100644 index 9aca047..0000000 --- a/lua/codetyper/core/llm/confidence.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/llm/copilot.lua b/lua/codetyper/core/llm/copilot.lua index 9376fce..b1d2b19 100644 --- a/lua/codetyper/core/llm/copilot.lua +++ b/lua/codetyper/core/llm/copilot.lua @@ -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(" 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 diff --git a/lua/codetyper/core/llm/gemini.lua b/lua/codetyper/core/llm/gemini.lua index 233c863..6585328 100644 --- a/lua/codetyper/core/llm/gemini.lua +++ b/lua/codetyper/core/llm/gemini.lua @@ -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 diff --git a/lua/codetyper/core/llm/ollama.lua b/lua/codetyper/core/llm/ollama.lua index a8aac68..9b520b9 100644 --- a/lua/codetyper/core/llm/ollama.lua +++ b/lua/codetyper/core/llm/ollama.lua @@ -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 diff --git a/lua/codetyper/core/llm/openai.lua b/lua/codetyper/core/llm/openai.lua index e4d8d6c..5bb23dc 100644 --- a/lua/codetyper/core/llm/openai.lua +++ b/lua/codetyper/core/llm/openai.lua @@ -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 diff --git a/lua/codetyper/core/llm/parser.lua b/lua/codetyper/core/llm/parser.lua deleted file mode 100644 index 270566c..0000000 --- a/lua/codetyper/core/llm/parser.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/scheduler/executor.lua b/lua/codetyper/core/scheduler/executor.lua deleted file mode 100644 index ddb5d32..0000000 --- a/lua/codetyper/core/scheduler/executor.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/scheduler/loop.lua b/lua/codetyper/core/scheduler/loop.lua deleted file mode 100644 index 2f73222..0000000 --- a/lua/codetyper/core/scheduler/loop.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/scheduler/resume.lua b/lua/codetyper/core/scheduler/resume.lua deleted file mode 100644 index fcf3052..0000000 --- a/lua/codetyper/core/scheduler/resume.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/scheduler/scheduler.lua b/lua/codetyper/core/scheduler/scheduler.lua deleted file mode 100644 index c002369..0000000 --- a/lua/codetyper/core/scheduler/scheduler.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/scheduler/worker.lua b/lua/codetyper/core/scheduler/worker.lua deleted file mode 100644 index 103c4c1..0000000 --- a/lua/codetyper/core/scheduler/worker.lua +++ /dev/null @@ -1,1034 +0,0 @@ ----@mod codetyper.agent.worker Async LLM worker wrapper ----@brief [[ ---- Wraps LLM clients with timeout handling and confidence scoring. ---- Provides unified interface for scheduler to dispatch work. ----@brief ]] - -local M = {} - -local params = require("codetyper.params.agents.worker") -local confidence = require("codetyper.core.llm.confidence") - ----@class WorkerResult ----@field success boolean Whether the request succeeded ----@field response string|nil The generated code ----@field error string|nil Error message if failed ----@field confidence number Confidence score (0.0-1.0) ----@field confidence_breakdown table Detailed confidence breakdown ----@field duration number Time taken in seconds ----@field worker_type string LLM provider used ----@field usage table|nil Token usage if available - ----@class Worker ----@field id string Worker ID ----@field event table PromptEvent being processed ----@field worker_type string LLM provider type ----@field status string "pending"|"running"|"completed"|"failed"|"timeout" ----@field start_time number Start timestamp ----@field timeout_ms number Timeout in milliseconds ----@field timer any Timeout timer handle ----@field callback function Result callback - ---- Worker ID counter -local worker_counter = 0 - ---- Patterns that indicate LLM needs more context (must be near start of response) -local context_needed_patterns = params.context_needed_patterns - ---- Check if response indicates need for more context ---- Only triggers if the response primarily asks for context (no substantial code) ----@param response string ----@return boolean -local function needs_more_context(response) - if not response then - return false - end - - -- If response has substantial code (more than 5 lines with code-like content), don't ask for context - local lines = vim.split(response, "\n") - local code_lines = 0 - for _, line in ipairs(lines) do - -- Count lines that look like code (have programming constructs) - if line:match("[{}();=]") or line:match("function") or line:match("def ") - or line:match("class ") or line:match("return ") or line:match("import ") - or line:match("public ") or line:match("private ") or line:match("local ") then - code_lines = code_lines + 1 - end - end - - -- If there's substantial code, don't trigger context request - if code_lines >= 3 then - return false - end - - -- Check if the response STARTS with a context-needed phrase - local lower = response:lower() - for _, pattern in ipairs(context_needed_patterns) do - if lower:match(pattern) then - return true - end - end - return false -end - ---- Check if response contains SEARCH/REPLACE blocks ----@param response string ----@return boolean -local function has_search_replace_blocks(response) - if not response then - return false - end - -- Check for any of the supported SEARCH/REPLACE formats - return response:match("<<<<<<<%s*SEARCH") ~= nil - or response:match("%-%-%-%-%-%-%-?%s*SEARCH") ~= nil - or response:match("%[SEARCH%]") ~= nil -end - ---- Clean LLM response to extract only code ----@param response string Raw LLM response ----@param filetype string|nil File type for language detection ----@return string Cleaned code -local function clean_response(response, filetype) - if not response then - return "" - end - - local cleaned = response - - -- Remove LLM special tokens (deepseek, llama, etc.) - cleaned = cleaned:gsub("<|begin▁of▁sentence|>", "") - cleaned = cleaned:gsub("<|end▁of▁sentence|>", "") - cleaned = cleaned:gsub("<|im_start|>", "") - cleaned = cleaned:gsub("<|im_end|>", "") - cleaned = cleaned:gsub("", "") - cleaned = cleaned:gsub("", "") - cleaned = cleaned:gsub("<|endoftext|>", "") - - -- Remove the original prompt tags /@ ... @/ if they appear in output - -- Use [%s%S] to match any character including newlines (Lua's . doesn't match newlines) - cleaned = cleaned:gsub("/@[%s%S]-@/", "") - - -- IMPORTANT: If response contains SEARCH/REPLACE blocks, preserve them! - -- Don't extract from markdown or remove "explanations" that are actually part of the format - if has_search_replace_blocks(cleaned) then - -- Just trim whitespace and return - the blocks will be parsed by search_replace module - return cleaned:match("^%s*(.-)%s*$") or cleaned - end - - -- Try to extract code from markdown code blocks - -- Match ```language\n...\n``` or just ```\n...\n``` - local code_block = cleaned:match("```[%w]*\n(.-)\n```") - if not code_block then - -- Try without newline after language - code_block = cleaned:match("```[%w]*(.-)\n```") - end - if not code_block then - -- Try single line code block - code_block = cleaned:match("```(.-)```") - end - - if code_block then - cleaned = code_block - else - -- No code block found, try to remove common prefixes/suffixes - -- Remove common apology/explanation phrases at the start - local explanation_starts = { - "^[Ii]'m sorry.-\n", - "^[Ii] apologize.-\n", - "^[Hh]ere is.-:\n", - "^[Hh]ere's.-:\n", - "^[Tt]his is.-:\n", - "^[Bb]ased on.-:\n", - "^[Ss]ure.-:\n", - "^[Oo][Kk].-:\n", - "^[Cc]ertainly.-:\n", - } - for _, pattern in ipairs(explanation_starts) do - cleaned = cleaned:gsub(pattern, "") - end - - -- Remove trailing explanations - local explanation_ends = { - "\n[Tt]his code.-$", - "\n[Tt]his function.-$", - "\n[Tt]his is a.-$", - "\n[Ii] hope.-$", - "\n[Ll]et me know.-$", - "\n[Ff]eel free.-$", - "\n[Nn]ote:.-$", - "\n[Pp]lease replace.-$", - "\n[Pp]lease note.-$", - "\n[Yy]ou might want.-$", - "\n[Yy]ou may want.-$", - "\n[Mm]ake sure.-$", - "\n[Aa]lso,.-$", - "\n[Rr]emember.-$", - } - for _, pattern in ipairs(explanation_ends) do - cleaned = cleaned:gsub(pattern, "") - end - end - - -- Remove any remaining markdown artifacts - cleaned = cleaned:gsub("^```[%w]*\n?", "") - cleaned = cleaned:gsub("\n?```$", "") - - -- Trim whitespace - cleaned = cleaned:match("^%s*(.-)%s*$") or cleaned - - return cleaned -end - ---- Active workers ----@type table -local active_workers = {} - ---- Default timeouts by provider type -local default_timeouts = params.default_timeouts - ---- Generate worker ID ----@return string -local function generate_id() - worker_counter = worker_counter + 1 - return string.format("worker_%d_%d", os.time(), worker_counter) -end - ---- Get LLM client by type ----@param worker_type string ----@return table|nil client ----@return string|nil error -local function get_client(worker_type) - local ok, client = pcall(require, "codetyper.llm." .. worker_type) - if ok and client then - return client, nil - end - return nil, "Unknown provider: " .. worker_type -end - ---- Format attached files for inclusion in prompt ----@param attached_files table[]|nil ----@return string -local function format_attached_files(attached_files) - if not attached_files or #attached_files == 0 then - return "" - end - - local parts = { "\n\n--- Referenced Files ---" } - for _, file in ipairs(attached_files) do - local ext = vim.fn.fnamemodify(file.path, ":e") - table.insert(parts, string.format( - "\n\nFile: %s\n```%s\n%s\n```", - file.path, - ext, - file.content:sub(1, 3000) -- Limit each file to 3000 chars - )) - end - - return table.concat(parts, "") -end - ---- Get coder companion file path for a target file ----@param target_path string Target file path ----@return string|nil Coder file path if exists -local function get_coder_companion_path(target_path) - if not target_path or target_path == "" then - return nil - end - - -- Skip if target is already a coder file - if target_path:match("%.coder%.") then - return nil - end - - local dir = vim.fn.fnamemodify(target_path, ":h") - local name = vim.fn.fnamemodify(target_path, ":t:r") -- filename without extension - local ext = vim.fn.fnamemodify(target_path, ":e") - - local coder_path = dir .. "/" .. name .. ".coder." .. ext - if vim.fn.filereadable(coder_path) == 1 then - return coder_path - end - - return nil -end - ---- Read and format coder companion context (business logic, pseudo-code) ----@param target_path string Target file path ----@return string Formatted coder context -local function get_coder_context(target_path) - local coder_path = get_coder_companion_path(target_path) - if not coder_path then - return "" - end - - local ok, lines = pcall(function() - return vim.fn.readfile(coder_path) - end) - - if not ok or not lines or #lines == 0 then - return "" - end - - local content = table.concat(lines, "\n") - - -- Skip if only template comments (no actual content) - local stripped = content:gsub("^%s*", ""):gsub("%s*$", "") - if stripped == "" then - return "" - end - - -- Check if there's meaningful content (not just template) - local has_content = false - for _, line in ipairs(lines) do - -- Skip comment lines that are part of the template - local trimmed = line:gsub("^%s*", "") - if not trimmed:match("^[%-#/]+%s*Coder companion") - and not trimmed:match("^[%-#/]+%s*Use /@ @/") - and not trimmed:match("^[%-#/]+%s*Example:") - and not trimmed:match("^ 0 then - table.insert(symbol_list, symbol .. " (in " .. files[1] .. ")") - end - end - if #symbol_list > 0 then - table.insert(parts, "Relevant symbols: " .. table.concat(symbol_list, ", ")) - end - end - - -- Learned patterns - if indexed_context.patterns and #indexed_context.patterns > 0 then - local pattern_list = {} - for i, p in ipairs(indexed_context.patterns) do - if i <= 3 then - table.insert(pattern_list, p.content or "") - end - end - if #pattern_list > 0 then - table.insert(parts, "Project conventions: " .. table.concat(pattern_list, "; ")) - end - end - - if #parts == 0 then - return "" - end - - return "\n\n--- Project Context ---\n" .. table.concat(parts, "\n") -end - ---- Check if this is an inline prompt (tags in target file, not a coder file) ----@param event table ----@return boolean -local function is_inline_prompt(event) - -- Inline prompts have a range with start_line/end_line from tag detection - -- and the source file is the same as target (not a .coder. file) - if not event.range or not event.range.start_line then - return false - end - -- Check if source path (if any) equals target, or if target has no .coder. in it - local target = event.target_path or "" - if target:match("%.coder%.") then - return false - end - return true -end - ---- Build file content with marked region for inline prompts ----@param lines string[] File lines ----@param start_line number 1-indexed ----@param end_line number 1-indexed ----@param prompt_content string The prompt inside the tags ----@return string -local function build_marked_file_content(lines, start_line, end_line, prompt_content) - local result = {} - for i, line in ipairs(lines) do - if i == start_line then - -- Mark the start of the region to be replaced - table.insert(result, ">>> REPLACE THIS REGION (lines " .. start_line .. "-" .. end_line .. ") <<<") - table.insert(result, "--- User request: " .. prompt_content:gsub("\n", " "):sub(1, 100) .. " ---") - end - table.insert(result, line) - if i == end_line then - table.insert(result, ">>> END OF REGION TO REPLACE <<<") - end - end - return table.concat(result, "\n") -end - ---- Build prompt for code generation ----@param event table PromptEvent ----@return string prompt ----@return table context -local function build_prompt(event) - local intent_mod = require("codetyper.core.intent") - - -- Get target file content for context - local target_content = "" - local target_lines = {} - if event.target_path then - local ok, lines = pcall(function() - return vim.fn.readfile(event.target_path) - end) - if ok and lines then - target_lines = lines - target_content = table.concat(lines, "\n") - end - end - - local filetype = vim.fn.fnamemodify(event.target_path or "", ":e") - - -- Get indexed project context - local indexed_context = nil - local indexed_content = "" - pcall(function() - local indexer = require("codetyper.features.indexer") - indexed_context = indexer.get_context_for({ - file = event.target_path, - intent = event.intent, - prompt = event.prompt_content, - scope = event.scope_text, - }) - indexed_content = format_indexed_context(indexed_context) - end) - - -- Format attached files - local attached_content = format_attached_files(event.attached_files) - - -- Get coder companion context (business logic, pseudo-code) - local coder_context = get_coder_context(event.target_path) - - -- Get brain memories - contextual recall based on current task - local brain_context = "" - pcall(function() - local brain = require("codetyper.core.memory") - if brain.is_initialized() then - -- Query brain for relevant memories based on: - -- 1. Current file (file-specific patterns) - -- 2. Prompt content (semantic similarity) - -- 3. Intent type (relevant past generations) - local query_text = event.prompt_content or "" - if event.scope and event.scope.name then - query_text = event.scope.name .. " " .. query_text - end - - local result = brain.query({ - query = query_text, - file = event.target_path, - max_results = 5, - types = { "pattern", "correction", "convention" }, - }) - - if result and result.nodes and #result.nodes > 0 then - local memories = { "\n\n--- Learned Patterns & Conventions ---" } - for _, node in ipairs(result.nodes) do - if node.c then - local summary = node.c.s or "" - local detail = node.c.d or "" - if summary ~= "" then - table.insert(memories, "• " .. summary) - if detail ~= "" and #detail < 200 then - table.insert(memories, " " .. detail) - end - end - end - end - if #memories > 1 then - brain_context = table.concat(memories, "\n") - end - end - end - end) - - -- Combine all context sources: brain memories first, then coder context, attached files, indexed - local extra_context = brain_context .. coder_context .. attached_content .. indexed_content - - -- Build context with scope information - local context = { - target_path = event.target_path, - target_content = target_content, - filetype = filetype, - scope = event.scope, - scope_text = event.scope_text, - scope_range = event.scope_range, - intent = event.intent, - attached_files = event.attached_files, - indexed_context = indexed_context, - } - - -- Build the actual prompt based on intent and scope - local system_prompt = "" - local user_prompt = event.prompt_content - - if event.intent then - system_prompt = intent_mod.get_prompt_modifier(event.intent) - end - - -- SPECIAL HANDLING: Inline prompts with /@ ... @/ tags - -- Uses SEARCH/REPLACE block format for reliable code editing - if is_inline_prompt(event) and event.range and event.range.start_line then - local start_line = event.range.start_line - local end_line = event.range.end_line or start_line - - -- Build full file content WITHOUT the /@ @/ tags for cleaner context - local file_content_clean = {} - for i, line in ipairs(target_lines) do - -- Skip lines that are part of the tag - if i < start_line or i > end_line then - table.insert(file_content_clean, line) - end - end - - user_prompt = string.format( - [[You are editing a %s file: %s - -TASK: %s - -FULL FILE CONTENT: -```%s -%s -``` - -IMPORTANT: The instruction above may ask you to make changes ANYWHERE in the file (e.g., "at the top", "after function X", etc.). Read the instruction carefully to determine WHERE to apply the change. - -INSTRUCTIONS: -You MUST respond using SEARCH/REPLACE blocks. This format lets you precisely specify what to find and what to replace it with. - -FORMAT: -<<<<<<< SEARCH -[exact lines to find in the file - copy them exactly including whitespace] -======= -[new lines to replace them with] ->>>>>>> REPLACE - -RULES: -1. The SEARCH section must contain EXACT lines from the file (copy-paste them) -2. Include 2-3 context lines to uniquely identify the location -3. The REPLACE section contains the modified code -4. You can use multiple SEARCH/REPLACE blocks for multiple changes -5. Preserve the original indentation style -6. If adding new code at the start/end of file, include the first/last few lines in SEARCH - -EXAMPLES: - -Example 1 - Adding code at the TOP of file: -Task: "Add a comment at the top" -<<<<<<< SEARCH -// existing first line -// existing second line -======= -// NEW COMMENT ADDED HERE -// existing first line -// existing second line ->>>>>>> REPLACE - -Example 2 - Modifying a function: -Task: "Add validation to setValue" -<<<<<<< SEARCH -export function setValue(key, value) { - cache.set(key, value); -} -======= -export function setValue(key, value) { - if (!key) throw new Error("key required"); - cache.set(key, value); -} ->>>>>>> REPLACE - -Now apply the requested changes using SEARCH/REPLACE blocks:]], - filetype, - vim.fn.fnamemodify(event.target_path or "", ":t"), - event.prompt_content, - filetype, - table.concat(file_content_clean, "\n"):sub(1, 8000) -- Limit size - ) - - context.system_prompt = system_prompt - context.formatted_prompt = user_prompt - context.is_inline_prompt = true - context.use_search_replace = true - - return user_prompt, context - end - - -- If we have a scope (function/method), include it in the prompt - if event.scope_text and event.scope and event.scope.type ~= "file" then - local scope_type = event.scope.type - local scope_name = event.scope.name or "anonymous" - - -- Special handling for "complete" intent - fill in the function body - if event.intent and event.intent.type == "complete" then - user_prompt = string.format( - [[Complete this %s. Fill in the implementation based on the description. - -IMPORTANT: -- Keep the EXACT same function signature (name, parameters, return type) -- Only provide the COMPLETE function with implementation -- Do NOT create a new function or duplicate the signature -- Do NOT add any text before or after the function - -Current %s (incomplete): -```%s -%s -``` -%s -What it should do: %s - -Return ONLY the complete %s with implementation. No explanations, no duplicates.]], - scope_type, - scope_type, - filetype, - event.scope_text, - extra_context, - event.prompt_content, - scope_type - ) - -- Remind the LLM not to repeat the original file content; ask for only the new/updated code or a unified diff - user_prompt = user_prompt .. [[ - -IMPORTANT: Do NOT repeat the existing code provided above. Return ONLY the new or modified code (the updated function body). If you modify the file, prefer outputting a unified diff patch using standard diff headers (--- a/ / +++ b/ and @@ hunks). No explanations, no markdown, no code fences. -]] - -- For other replacement intents, provide the full scope to transform - elseif event.intent and intent_mod.is_replacement(event.intent) then - user_prompt = string.format( - [[Here is a %s named "%s" in a %s file: - -```%s -%s -``` -%s -User request: %s - -Return the complete transformed %s. Output only code, no explanations.]], - scope_type, - scope_name, - filetype, - filetype, - event.scope_text, - extra_context, - event.prompt_content, - scope_type - ) - else - -- For insertion intents, provide context - user_prompt = string.format( - [[Context - this code is inside a %s named "%s": - -```%s -%s -``` -%s -User request: %s - -Output only the code to insert, no explanations.]], - scope_type, - scope_name, - filetype, - event.scope_text, - extra_context, - event.prompt_content - ) - - -- Remind the LLM not to repeat the full file content; ask for only the new/modified code or unified diff - user_prompt = user_prompt .. [[ - -IMPORTANT: Do NOT repeat the full file content shown above. Return ONLY the new or modified code required to satisfy the request. If you modify the file, prefer outputting a unified diff patch using standard diff headers (--- a/ / +++ b/ and @@ hunks). No explanations, no markdown, no code fences. -]] - - -- Remind the LLM not to repeat the original file content; ask for only the inserted code or a unified diff - user_prompt = user_prompt .. [[ - -IMPORTANT: Do NOT repeat the surrounding code provided above. Return ONLY the code to insert (the new snippet). If you modify multiple parts of the file, prefer outputting a unified diff patch using standard diff headers (--- a/ / +++ b/ and @@ hunks). No explanations, no markdown, no code fences. -]] - end - else - -- No scope resolved, use full file context - user_prompt = string.format( - [[File: %s (%s) - -```%s -%s -``` -%s -User request: %s - -Output only code, no explanations.]], - vim.fn.fnamemodify(event.target_path or "", ":t"), - filetype, - filetype, - target_content:sub(1, 4000), -- Limit context size - extra_context, - event.prompt_content - ) - end - - context.system_prompt = system_prompt - context.formatted_prompt = user_prompt - - return user_prompt, context -end - ---- Create and start a worker ----@param event table PromptEvent ----@param worker_type string LLM provider type ----@param callback function(result: WorkerResult) ----@return Worker -function M.create(event, worker_type, callback) - local worker = { - id = generate_id(), - event = event, - worker_type = worker_type, - status = "pending", - start_time = os.clock(), - timeout_ms = default_timeouts[worker_type] or 60000, - callback = callback, - } - - active_workers[worker.id] = worker - - -- Log worker creation - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "worker", - message = string.format("Worker %s started (%s)", worker.id, worker_type), - data = { - worker_id = worker.id, - event_id = event.id, - provider = worker_type, - }, - }) - end) - - -- Start the work - M.start(worker) - - return worker -end - ---- Start worker execution ----@param worker Worker -function M.start(worker) - worker.status = "running" - - -- Set up timeout - worker.timer = vim.defer_fn(function() - if worker.status == "running" then - worker.status = "timeout" - active_workers[worker.id] = nil - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "warning", - message = string.format("Worker %s timed out after %dms", worker.id, worker.timeout_ms), - }) - end) - - worker.callback({ - success = false, - response = nil, - error = "timeout", - confidence = 0, - confidence_breakdown = {}, - duration = (os.clock() - worker.start_time), - worker_type = worker.worker_type, - }) - end - end, worker.timeout_ms) - - local prompt, context = build_prompt(worker.event) - - -- Check if smart selection is enabled (memory-based provider selection) - local use_smart_selection = false - pcall(function() - local codetyper = require("codetyper") - local config = codetyper.get_config() - use_smart_selection = config.llm.smart_selection ~= false -- Default to true - end) - - -- Define the response handler - local function handle_response(response, err, usage_or_metadata) - -- Cancel timeout timer - if worker.timer then - pcall(function() - if type(worker.timer) == "userdata" and worker.timer.stop then - worker.timer:stop() - end - end) - end - - if worker.status ~= "running" then - return -- Already timed out or cancelled - end - - -- Extract usage from metadata if smart_generate was used - local usage = usage_or_metadata - if type(usage_or_metadata) == "table" and usage_or_metadata.provider then - -- This is metadata from smart_generate - usage = nil - -- Update worker type to reflect actual provider used - worker.worker_type = usage_or_metadata.provider - -- Log if pondering occurred - if usage_or_metadata.pondered then - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format( - "Pondering: %s (agreement: %.0f%%)", - usage_or_metadata.corrected and "corrected" or "validated", - (usage_or_metadata.agreement or 1) * 100 - ), - }) - end) - end - end - - M.complete(worker, response, err, usage) - end - - -- Use smart selection or direct client - if use_smart_selection then - local llm = require("codetyper.core.llm") - llm.smart_generate(prompt, context, handle_response) - else - -- Get client and execute directly - local client, client_err = get_client(worker.worker_type) - if not client then - M.complete(worker, nil, client_err) - return - end - client.generate(prompt, context, handle_response) - end -end - ---- Complete worker execution ----@param worker Worker ----@param response string|nil ----@param error string|nil ----@param usage table|nil -function M.complete(worker, response, error, usage) - local duration = os.clock() - worker.start_time - - if error then - worker.status = "failed" - active_workers[worker.id] = nil - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "error", - message = string.format("Worker %s failed: %s", worker.id, error), - }) - end) - - worker.callback({ - success = false, - response = nil, - error = error, - confidence = 0, - confidence_breakdown = {}, - duration = duration, - worker_type = worker.worker_type, - usage = usage, - }) - return - end - - -- Check if LLM needs more context - if needs_more_context(response) then - worker.status = "needs_context" - active_workers[worker.id] = nil - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format("Worker %s: LLM needs more context", worker.id), - }) - end) - - worker.callback({ - success = false, - response = response, - error = nil, - needs_context = true, - original_event = worker.event, - confidence = 0, - confidence_breakdown = {}, - duration = duration, - worker_type = worker.worker_type, - usage = usage, - }) - return - end - - -- Log the full raw LLM response (for debugging) - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "response", - message = "--- LLM Response ---", - data = { - raw_response = response, - }, - }) - end) - - -- Clean the response (remove markdown, explanations, etc.) - local filetype = vim.fn.fnamemodify(worker.event.target_path or "", ":e") - local cleaned_response = clean_response(response, filetype) - - -- Score confidence on cleaned response - local conf_score, breakdown = confidence.score(cleaned_response, worker.event.prompt_content) - - worker.status = "completed" - active_workers[worker.id] = nil - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "success", - message = string.format( - "Worker %s completed (%.2fs, confidence: %.2f - %s)", - worker.id, duration, conf_score, confidence.level_name(conf_score) - ), - data = { - confidence_breakdown = confidence.format_breakdown(breakdown), - usage = usage, - }, - }) - end) - - worker.callback({ - success = true, - response = cleaned_response, - error = nil, - confidence = conf_score, - confidence_breakdown = breakdown, - duration = duration, - worker_type = worker.worker_type, - usage = usage, - }) -end - ---- Cancel a worker ----@param worker_id string ----@return boolean -function M.cancel(worker_id) - local worker = active_workers[worker_id] - if not worker then - return false - end - - if worker.timer then - pcall(function() - if type(worker.timer) == "userdata" and worker.timer.stop then - worker.timer:stop() - end - end) - end - - worker.status = "cancelled" - active_workers[worker_id] = nil - - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.add({ - type = "info", - message = string.format("Worker %s cancelled", worker_id), - }) - end) - - return true -end - ---- Get active worker count ----@return number -function M.active_count() - local count = 0 - for _ in pairs(active_workers) do - count = count + 1 - end - return count -end - ---- Get all active workers ----@return Worker[] -function M.get_active() - local workers = {} - for _, worker in pairs(active_workers) do - table.insert(workers, worker) - end - return workers -end - ---- Check if worker exists and is running ----@param worker_id string ----@return boolean -function M.is_running(worker_id) - local worker = active_workers[worker_id] - return worker ~= nil and worker.status == "running" -end - ---- Cancel all workers for an event ----@param event_id string ----@return number cancelled_count -function M.cancel_for_event(event_id) - local cancelled = 0 - for id, worker in pairs(active_workers) do - if worker.event.id == event_id then - M.cancel(id) - cancelled = cancelled + 1 - end - end - return cancelled -end - ---- Set timeout for worker type ----@param worker_type string ----@param timeout_ms number -function M.set_timeout(worker_type, timeout_ms) - default_timeouts[worker_type] = timeout_ms -end - -return M diff --git a/lua/codetyper/core/scope/init.lua b/lua/codetyper/core/scope/init.lua deleted file mode 100644 index 4386830..0000000 --- a/lua/codetyper/core/scope/init.lua +++ /dev/null @@ -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 ----@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 diff --git a/lua/codetyper/core/tools/base.lua b/lua/codetyper/core/tools/base.lua deleted file mode 100644 index 866ddf6..0000000 --- a/lua/codetyper/core/tools/base.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/tools/bash.lua b/lua/codetyper/core/tools/bash.lua deleted file mode 100644 index 775b6c2..0000000 --- a/lua/codetyper/core/tools/bash.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/tools/edit.lua b/lua/codetyper/core/tools/edit.lua deleted file mode 100644 index 000bc3f..0000000 --- a/lua/codetyper/core/tools/edit.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/tools/glob.lua b/lua/codetyper/core/tools/glob.lua deleted file mode 100644 index 3355f1f..0000000 --- a/lua/codetyper/core/tools/glob.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/tools/grep.lua b/lua/codetyper/core/tools/grep.lua deleted file mode 100644 index 8f9172e..0000000 --- a/lua/codetyper/core/tools/grep.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/tools/init.lua b/lua/codetyper/core/tools/init.lua deleted file mode 100644 index 5765632..0000000 --- a/lua/codetyper/core/tools/init.lua +++ /dev/null @@ -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 - diff --git a/lua/codetyper/core/tools/registry.lua b/lua/codetyper/core/tools/registry.lua deleted file mode 100644 index fddfa88..0000000 --- a/lua/codetyper/core/tools/registry.lua +++ /dev/null @@ -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 -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 -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 diff --git a/lua/codetyper/core/tools/view.lua b/lua/codetyper/core/tools/view.lua deleted file mode 100644 index 32f4a45..0000000 --- a/lua/codetyper/core/tools/view.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/core/tools/write.lua b/lua/codetyper/core/tools/write.lua deleted file mode 100644 index 1480a84..0000000 --- a/lua/codetyper/core/tools/write.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/features/agents/context_builder.lua b/lua/codetyper/features/agents/context_builder.lua deleted file mode 100644 index 377509a..0000000 --- a/lua/codetyper/features/agents/context_builder.lua +++ /dev/null @@ -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 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 diff --git a/lua/codetyper/features/agents/engine.lua b/lua/codetyper/features/agents/engine.lua deleted file mode 100644 index 4233ff5..0000000 --- a/lua/codetyper/features/agents/engine.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/features/agents/init.lua b/lua/codetyper/features/agents/init.lua deleted file mode 100644 index 858f15c..0000000 --- a/lua/codetyper/features/agents/init.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/features/agents/linter.lua b/lua/codetyper/features/agents/linter.lua deleted file mode 100644 index 3291506..0000000 --- a/lua/codetyper/features/agents/linter.lua +++ /dev/null @@ -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 -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 diff --git a/lua/codetyper/features/agents/permissions.lua b/lua/codetyper/features/agents/permissions.lua deleted file mode 100644 index e9d6eb0..0000000 --- a/lua/codetyper/features/agents/permissions.lua +++ /dev/null @@ -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 Commands allowed for this session ----@field allow_list table Patterns always allowed ----@field deny_list table 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 diff --git a/lua/codetyper/features/ask/engine.lua b/lua/codetyper/features/ask/engine.lua deleted file mode 100644 index c7458e3..0000000 --- a/lua/codetyper/features/ask/engine.lua +++ /dev/null @@ -1,1282 +0,0 @@ ----@mod codetyper.ask Ask window for Codetyper.nvim - -local M = {} - -local utils = require("codetyper.support.utils") - ----@class AskState ----@field input_buf number|nil Input buffer ----@field input_win number|nil Input window ----@field output_buf number|nil Output buffer ----@field output_win number|nil Output window ----@field is_open boolean Whether the ask panel is open ----@field history table Chat history ----@field referenced_files table Files referenced with @ - ----@type AskState -local state = { - input_buf = nil, - input_win = nil, - output_buf = nil, - output_win = nil, - is_open = false, - history = {}, - referenced_files = {}, - target_width = nil, -- Store the target width to maintain it - agent_mode = false, -- Whether agent mode is enabled (can make file changes) - log_listener_id = nil, -- Listener ID for LLM logs - show_logs = true, -- Whether to show LLM logs in chat - selection_context = nil, -- Visual selection passed when opening -} - ---- Get the ask window configuration ----@return table Config -local function get_config() - local ok, codetyper = pcall(require, "codetyper") - if ok and codetyper.is_initialized() then - return codetyper.get_config() - end - return { - window = { width = 0.4, border = "rounded" }, - } -end - ---- Create the output buffer (chat history) ----@return number Buffer number -local function create_output_buffer() - local buf = vim.api.nvim_create_buf(false, true) - - vim.bo[buf].buftype = "nofile" - vim.bo[buf].bufhidden = "hide" - vim.bo[buf].swapfile = false - vim.bo[buf].filetype = "markdown" - - -- Set initial content - local header = { - "╔═════════════════════════════════╗", - "║ [ASK MODE] Q&A Chat ║", - "╠═════════════════════════════════╣", - "║ Ask about code or concepts ║", - "║ ║", - "║ @ → attach file ║", - "║ C-Enter → send ║", - "║ C-n → new chat ║", - "║ C-f → add current file ║", - "║ L → toggle LLM logs ║", - "║ :CoderType → switch mode ║", - "║ q → close │ K/J → jump ║", - "╚═════════════════════════════════╝", - "", - } - vim.api.nvim_buf_set_lines(buf, 0, -1, false, header) - - return buf -end - ---- Create the input buffer ----@return number Buffer number -local function create_input_buffer() - local buf = vim.api.nvim_create_buf(false, true) - - vim.bo[buf].buftype = "nofile" - vim.bo[buf].bufhidden = "hide" - vim.bo[buf].swapfile = false - vim.bo[buf].filetype = "markdown" - - -- Set placeholder text - local placeholder = { - "┌───────────────────────────────────┐", - "│ 💬 Type your question here... │", - "│ │", - "│ @ attach │ C-Enter send │ C-n new │", - "└───────────────────────────────────┘", - } - vim.api.nvim_buf_set_lines(buf, 0, -1, false, placeholder) - - return buf -end - ---- Setup keymaps for the input buffer ----@param buf number Buffer number -local function setup_input_keymaps(buf) - local opts = { buffer = buf, noremap = true, silent = true } - - -- Submit with Ctrl+Enter - vim.keymap.set("i", "", function() - M.submit() - end, opts) - - vim.keymap.set("n", "", function() - M.submit() - end, opts) - - vim.keymap.set("n", "", function() - M.submit() - end, opts) - - -- Include current file context with Ctrl+F - vim.keymap.set({ "n", "i" }, "", function() - M.include_file_context() - end, opts) - - -- File picker with @ - vim.keymap.set("i", "@", function() - M.show_file_picker() - end, opts) - - -- Close with q in normal mode - vim.keymap.set("n", "q", function() - M.close() - end, opts) - - -- Clear input with Ctrl+c - vim.keymap.set("n", "", function() - M.clear_input() - end, opts) - - -- New chat with Ctrl+n (clears everything) - vim.keymap.set({ "n", "i" }, "", function() - M.new_chat() - end, opts) - - -- Window navigation (works in both normal and insert mode) - vim.keymap.set({ "n", "i" }, "", function() - vim.cmd("wincmd h") - end, opts) - - vim.keymap.set({ "n", "i" }, "", function() - vim.cmd("wincmd j") - end, opts) - - vim.keymap.set({ "n", "i" }, "", function() - vim.cmd("wincmd k") - end, opts) - - vim.keymap.set({ "n", "i" }, "", function() - vim.cmd("wincmd l") - end, opts) - - -- Jump to output window - vim.keymap.set("n", "K", function() - M.focus_output() - end, opts) - - -- When entering insert mode, clear placeholder - vim.api.nvim_create_autocmd("InsertEnter", { - buffer = buf, - callback = function() - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local content = table.concat(lines, "\n") - if content:match("Type your question here") then - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "" }) - end - end, - }) -end - ---- Setup keymaps for the output buffer ----@param buf number Buffer number -local function setup_output_keymaps(buf) - local opts = { buffer = buf, noremap = true, silent = true } - - -- Close with q - vim.keymap.set("n", "q", function() - M.close() - end, opts) - - -- Clear history with Ctrl+c - vim.keymap.set("n", "", function() - M.clear_history() - end, opts) - - -- New chat with Ctrl+n (clears everything) - vim.keymap.set("n", "", function() - M.new_chat() - end, opts) - - -- Copy last response with Y - vim.keymap.set("n", "Y", function() - M.copy_last_response() - end, opts) - - -- Toggle LLM logs with L - vim.keymap.set("n", "L", function() - M.toggle_logs() - end, opts) - - -- Jump to input with i or J - vim.keymap.set("n", "i", function() - M.focus_input() - end, opts) - - vim.keymap.set("n", "J", function() - M.focus_input() - end, opts) - - -- Window navigation - vim.keymap.set("n", "", function() - vim.cmd("wincmd h") - end, opts) - - vim.keymap.set("n", "", function() - vim.cmd("wincmd j") - end, opts) - - vim.keymap.set("n", "", function() - vim.cmd("wincmd k") - end, opts) - - vim.keymap.set("n", "", function() - vim.cmd("wincmd l") - end, opts) -end - ---- Calculate window dimensions (always 1/4 of screen) ----@return table Dimensions -local function calculate_dimensions() - -- Always use 1/4 of the screen width - local width = math.floor(vim.o.columns * 0.25) - - return { - width = math.max(width, 30), -- Minimum 30 columns - total_height = vim.o.lines - 4, - output_height = vim.o.lines - 14, - input_height = 8, - } -end - ---- Autocmd group for maintaining width -local ask_augroup = nil - ---- Setup autocmd to always maintain 1/4 window width -local function setup_width_autocmd() - -- Clear previous autocmd group if exists - if ask_augroup then - pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) - end - - ask_augroup = vim.api.nvim_create_augroup("CodetypeAskWidth", { clear = true }) - - -- Always maintain 1/4 width on any window event - vim.api.nvim_create_autocmd({ "WinResized", "WinNew", "WinClosed", "VimResized" }, { - group = ask_augroup, - callback = function() - if not state.is_open or not state.output_win then - return - end - if not vim.api.nvim_win_is_valid(state.output_win) then - return - end - - vim.schedule(function() - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - -- Always calculate 1/4 of current screen width - local target_width = math.max(math.floor(vim.o.columns * 0.25), 30) - state.target_width = target_width - - local current_width = vim.api.nvim_win_get_width(state.output_win) - if current_width ~= target_width then - pcall(vim.api.nvim_win_set_width, state.output_win, target_width) - end - end - end) - end, - desc = "Maintain Ask panel at 1/4 window width", - }) -end - ---- Append log entry to output buffer ----@param entry table Log entry from agent/logs -local function append_log_to_output(entry) - if not state.show_logs then - return - end - - if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - return - end - - -- Skip clear events - if entry.level == "clear" then - return - end - - -- Format the log entry with icons - local icons = { - info = "ℹ️", - debug = "🔍", - request = "📤", - response = "📥", - tool = "🔧", - error = "❌", - warning = "⚠️", - } - - local icon = icons[entry.level] or "•" - -- Sanitize message - replace newlines with spaces to prevent nvim_buf_set_lines error - local sanitized_message = entry.message:gsub("\n", " "):gsub("\r", "") - local formatted = string.format("[%s] %s %s", entry.timestamp, icon, sanitized_message) - - vim.schedule(function() - if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - return - end - - vim.bo[state.output_buf].modifiable = true - - local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) - - -- Add a subtle log line - table.insert(lines, " " .. formatted) - - vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) - vim.bo[state.output_buf].modifiable = false - - -- Scroll to bottom - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - local line_count = vim.api.nvim_buf_line_count(state.output_buf) - pcall(vim.api.nvim_win_set_cursor, state.output_win, { line_count, 0 }) - end - end) -end - ---- Setup log listener for LLM logs -local function setup_log_listener() - -- Remove existing listener if any - if state.log_listener_id then - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.remove_listener(state.log_listener_id) - end) - state.log_listener_id = nil - end - - -- Add new listener - local ok, logs = pcall(require, "codetyper.agent.logs") - if ok then - state.log_listener_id = logs.add_listener(append_log_to_output) - end -end - ---- Remove log listener -local function remove_log_listener() - if state.log_listener_id then - pcall(function() - local logs = require("codetyper.adapters.nvim.ui.logs") - logs.remove_listener(state.log_listener_id) - end) - state.log_listener_id = nil - end -end - ---- Open the ask panel ----@param selection table|nil Visual selection context {text, start_line, end_line, filepath, filename, language} -function M.open(selection) - -- Use the is_open() function which validates window state - if M.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 for use in questions - state.selection_context = selection - - local dims = calculate_dimensions() - - -- Store the target width - state.target_width = dims.width - - -- Create buffers if they don't exist - if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - state.output_buf = create_output_buffer() - setup_output_keymaps(state.output_buf) - end - - if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then - state.input_buf = create_input_buffer() - setup_input_keymaps(state.input_buf) - end - - -- Save current window to return to it later - local current_win = vim.api.nvim_get_current_win() - - -- Create output window (top-left) - vim.cmd("topleft vsplit") - state.output_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(state.output_win, state.output_buf) - vim.api.nvim_win_set_width(state.output_win, dims.width) - - -- Window options for output - vim.wo[state.output_win].number = false - vim.wo[state.output_win].relativenumber = false - vim.wo[state.output_win].signcolumn = "no" - vim.wo[state.output_win].wrap = true - vim.wo[state.output_win].linebreak = true - vim.wo[state.output_win].cursorline = false - vim.wo[state.output_win].winfixwidth = true - - -- Create input window (bottom of the left panel) - 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, dims.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 - - state.is_open = true - - -- Setup log listener for LLM logs - setup_log_listener() - - -- Setup autocmd to maintain width - setup_width_autocmd() - - -- Setup autocmd to close both windows when one is closed - local close_group = vim.api.nvim_create_augroup("CodetypeAskClose", { clear = true }) - - vim.api.nvim_create_autocmd("WinClosed", { - group = close_group, - callback = function(args) - local closed_win = tonumber(args.match) - -- Check if one of our windows was closed - if closed_win == state.output_win or closed_win == state.input_win then - -- Defer to avoid issues during window close - vim.schedule(function() - -- Close the other window if it's still open - if closed_win == state.output_win then - 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 - elseif closed_win == state.input_win then - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - pcall(vim.api.nvim_win_close, state.output_win, true) - end - end - - -- Reset state - state.input_win = nil - state.output_win = nil - state.is_open = false - state.target_width = nil - - -- Remove log listener - remove_log_listener() - - -- Clean up autocmd groups - pcall(vim.api.nvim_del_augroup_by_id, close_group) - if ask_augroup then - pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) - ask_augroup = nil - end - end) - end - end, - desc = "Close both Ask windows together", - }) - - -- Focus the input window and start insert mode - vim.api.nvim_set_current_win(state.input_win) - vim.cmd("startinsert") - - -- 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 -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.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - return - end - - state.selection_context = selection - - vim.bo[state.output_buf].modifiable = true - - local lines = vim.api.nvim_buf_get_lines(state.output_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 with syntax hints - local lang = selection.language or "text" - 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, "Ask about this code or describe what you'd like to do with it.") - - for _, line in ipairs(new_lines) do - table.insert(lines, line) - end - - vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) - vim.bo[state.output_buf].modifiable = false - - -- Scroll to bottom - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - local line_count = vim.api.nvim_buf_line_count(state.output_buf) - vim.api.nvim_win_set_cursor(state.output_win, { line_count, 0 }) - end -end - ---- Show file picker for @ mentions -function M.show_file_picker() - -- Check if telescope is available - local has_telescope, telescope = pcall(require, "telescope.builtin") - - if has_telescope then - telescope.find_files({ - prompt_title = "Select file to reference (@)", - 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 - -- Fallback: simple input - vim.ui.input({ prompt = "Enter 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 to the input ----@param filepath string Full path to the file ----@param filename string Display name -function M.add_file_reference(filepath, filename) - -- Normalize filepath - filepath = vim.fn.fnamemodify(filepath, ":p") - - -- Store the reference with full path - state.referenced_files[filename] = filepath - - -- Read and validate file exists - local content = utils.read_file(filepath) - if not content then - utils.notify("Warning: Could not read file: " .. filename, vim.log.levels.WARN) - end - - -- Add to input buffer - if state.input_buf and vim.api.nvim_buf_is_valid(state.input_buf) then - local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false) - local text = table.concat(lines, "\n") - - -- Clear placeholder if present - if text:match("Type your question here") then - text = "" - end - - -- Add file reference (with single @) - local reference = "[📎 " .. filename .. "] " - text = text .. reference - - vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, vim.split(text, "\n")) - - -- Move cursor to end - if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then - vim.api.nvim_set_current_win(state.input_win) - local line_count = vim.api.nvim_buf_line_count(state.input_buf) - local last_line = vim.api.nvim_buf_get_lines(state.input_buf, line_count - 1, line_count, false)[1] or "" - vim.api.nvim_win_set_cursor(state.input_win, { line_count, #last_line }) - vim.cmd("startinsert!") - end - end - - utils.notify("Added file: " .. filename .. " (" .. (content and #content or 0) .. " bytes)") -end - ---- Close the ask panel -function M.close() - -- Remove the log listener - remove_log_listener() - - -- Remove the width maintenance autocmd first - if ask_augroup then - pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) - ask_augroup = nil - end - - -- Find a window to focus after closing (not the ask windows) - local target_win = nil - for _, win in ipairs(vim.api.nvim_list_wins()) do - local buf = vim.api.nvim_win_get_buf(win) - if win ~= state.input_win and win ~= state.output_win then - local buftype = vim.bo[buf].buftype - if buftype == "" or buftype == "acwrite" then - target_win = win - break - end - end - end - - -- Close input window - 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 - - -- Close output window - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - pcall(vim.api.nvim_win_close, state.output_win, true) - end - - -- Reset state - state.input_win = nil - state.output_win = nil - state.is_open = false - state.target_width = nil - - -- Focus the target window if found, otherwise focus first available - if target_win and vim.api.nvim_win_is_valid(target_win) then - pcall(vim.api.nvim_set_current_win, target_win) - else - -- If no valid window, make sure we're not left with empty state - local wins = vim.api.nvim_list_wins() - if #wins > 0 then - pcall(vim.api.nvim_set_current_win, wins[1]) - end - end -end - ---- Toggle the ask panel -function M.toggle() - if M.is_open() then - M.close() - else - M.open() - end -end - ---- Focus the input window -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 output window -function M.focus_output() - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - vim.api.nvim_set_current_win(state.output_win) - end -end - ---- Get input text ----@return string Input text -local function get_input_text() - 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 content = table.concat(lines, "\n") - - -- Ignore placeholder - if content:match("Type your question here") then - return "" - end - - return content -end - ---- Clear input buffer -function M.clear_input() - if state.input_buf and vim.api.nvim_buf_is_valid(state.input_buf) then - vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, { "" }) - end - state.referenced_files = {} -end - ---- Append text to output buffer ----@param text string Text to append ----@param is_user boolean Whether this is user message -local function append_to_output(text, is_user) - if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - return - end - - vim.bo[state.output_buf].modifiable = true - - local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) - - local timestamp = os.date("%H:%M") - local header = is_user and "┌─ 👤 You [" .. timestamp .. "] ────────" - or "┌─ 🤖 AI [" .. timestamp .. "] ──────────" - - local new_lines = { "", header, "│" } - - -- Add text lines with border - for _, line in ipairs(vim.split(text, "\n")) do - table.insert(new_lines, "│ " .. line) - end - - table.insert( - new_lines, - "└─────────────────────────────────" - ) - - for _, line in ipairs(new_lines) do - table.insert(lines, line) - end - - vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) - vim.bo[state.output_buf].modifiable = false - - -- Scroll to bottom - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - local line_count = vim.api.nvim_buf_line_count(state.output_buf) - vim.api.nvim_win_set_cursor(state.output_win, { line_count, 0 }) - end -end - ---- Build context from referenced files ----@return string Context string, number File count -local function build_file_context() - local context = "" - local file_count = 0 - - for filename, filepath in pairs(state.referenced_files) do - local content = utils.read_file(filepath) - if content and content ~= "" then - -- Detect language from extension - local ext = vim.fn.fnamemodify(filepath, ":e") - local lang = ext or "text" - - context = context .. "\n\n=== FILE: " .. filename .. " ===\n" - context = context .. "Path: " .. filepath .. "\n" - context = context .. "```" .. lang .. "\n" .. content .. "\n```\n" - file_count = file_count + 1 - end - end - - return context, file_count -end - ---- Build context for the question ----@param intent? table Detected intent from intent module ----@return table Context object -local function build_context(intent) - local context = { - project_root = utils.get_project_root(), - current_file = nil, - current_content = nil, - language = nil, - referenced_files = state.referenced_files, - brain_context = nil, - indexer_context = nil, - } - - -- Try to get current file context from the non-ask window - local wins = vim.api.nvim_list_wins() - for _, win in ipairs(wins) do - if win ~= state.input_win and win ~= state.output_win then - local buf = vim.api.nvim_win_get_buf(win) - local filepath = vim.api.nvim_buf_get_name(buf) - - if filepath and filepath ~= "" and not filepath:match("%.coder%.") then - context.current_file = filepath - context.current_content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") - context.language = vim.bo[buf].filetype - break - end - end - end - - -- Add brain context if intent needs it - if intent and intent.needs_brain_context then - local ok_brain, brain = pcall(require, "codetyper.brain") - if ok_brain and brain.is_initialized() then - context.brain_context = brain.get_context_for_llm({ - file = context.current_file, - max_tokens = 1000, - }) - end - end - - -- Add indexer context if intent needs project-wide context - if intent and intent.needs_project_context then - local ok_indexer, indexer = pcall(require, "codetyper.indexer") - if ok_indexer then - context.indexer_context = indexer.get_context_for({ - file = context.current_file, - prompt = "", -- Will be filled later - intent = intent, - }) - end - end - - return context -end - ---- Append exploration log to output buffer ----@param msg string ----@param level string -local function append_exploration_log(msg, level) - if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - return - end - - vim.schedule(function() - if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then - return - end - - vim.bo[state.output_buf].modifiable = true - - local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) - - -- Format based on level - local formatted = msg - if level == "progress" then - formatted = msg - elseif level == "debug" then - formatted = msg - elseif level == "file" then - formatted = msg - end - - table.insert(lines, formatted) - - vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) - vim.bo[state.output_buf].modifiable = false - - -- Scroll to bottom - if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then - local line_count = vim.api.nvim_buf_line_count(state.output_buf) - pcall(vim.api.nvim_win_set_cursor, state.output_win, { line_count, 0 }) - end - end) -end - ---- Continue submission after exploration ----@param question string ----@param intent table ----@param context table ----@param file_context string ----@param file_count number ----@param exploration_result table|nil -local function continue_submit(question, intent, context, file_context, file_count, exploration_result) - -- Get prompt type based on intent - local ok_intent, intent_module = pcall(require, "codetyper.ask.intent") - local prompt_type = "ask" - if ok_intent then - prompt_type = intent_module.get_prompt_type(intent) - end - - -- Build system prompt using prompts module - local prompts = require("codetyper.prompts") - local system_prompt = prompts.system[prompt_type] or prompts.system.ask - - if context.current_file then - system_prompt = system_prompt .. "\n\nCurrent open file: " .. context.current_file - system_prompt = system_prompt .. "\nLanguage: " .. (context.language or "unknown") - end - - -- Add exploration context if available - if exploration_result then - local ok_explorer, explorer = pcall(require, "codetyper.ask.explorer") - if ok_explorer then - local explore_context = explorer.build_context(exploration_result) - system_prompt = system_prompt .. "\n\n=== PROJECT EXPLORATION RESULTS ===\n" - system_prompt = system_prompt .. explore_context - system_prompt = system_prompt .. "\n=== END EXPLORATION ===\n" - end - end - - -- Add brain context (learned patterns, conventions) - if context.brain_context and context.brain_context ~= "" then - system_prompt = system_prompt .. "\n\n=== LEARNED PROJECT KNOWLEDGE ===\n" - system_prompt = system_prompt .. context.brain_context - system_prompt = system_prompt .. "\n=== END LEARNED KNOWLEDGE ===\n" - end - - -- Add indexer context (project structure, symbols) - if context.indexer_context then - local idx_ctx = context.indexer_context - if idx_ctx.project_type and idx_ctx.project_type ~= "unknown" then - system_prompt = system_prompt .. "\n\nProject type: " .. idx_ctx.project_type - end - if idx_ctx.relevant_symbols and next(idx_ctx.relevant_symbols) then - system_prompt = system_prompt .. "\n\nRelevant symbols in project:" - for symbol, files in pairs(idx_ctx.relevant_symbols) do - system_prompt = system_prompt .. "\n - " .. symbol .. " (in: " .. table.concat(files, ", ") .. ")" - end - end - if idx_ctx.patterns and #idx_ctx.patterns > 0 then - system_prompt = system_prompt .. "\n\nProject patterns/memories:" - for _, pattern in ipairs(idx_ctx.patterns) do - system_prompt = system_prompt .. "\n - " .. (pattern.summary or pattern.content or "") - end - end - end - - -- Add to history - table.insert(state.history, { role = "user", content = question }) - - -- Show loading indicator - append_to_output("", false) - append_to_output("⏳ Generating response...", false) - - -- Get LLM client and generate response - local ok, llm = pcall(require, "codetyper.llm") - if not ok then - append_to_output("❌ Error: LLM module not loaded", false) - return - end - - local client = llm.get_client() - - -- Build recent conversation context (limit to last N entries) - local history_context = "" - do - local max_entries = 8 - local total = #state.history - local start_i = 1 - if total > max_entries then - start_i = total - max_entries + 1 - end - if total > 0 then - history_context = "\n\n=== PREVIOUS CONVERSATION ===\n" - for i = start_i, total do - local m = state.history[i] - local role = (m.role == "assistant") and "ASSISTANT" or "USER" - history_context = history_context .. role .. ": " .. (m.content or "") .. "\n" - end - history_context = history_context .. "=== END PREVIOUS CONVERSATION ===\n\n" - end - end - - -- Build full prompt starting with recent conversation + user question - local full_prompt = history_context .. "USER QUESTION: " .. question - - -- Add visual selection context if present - if state.selection_context and state.selection_context.text and state.selection_context.text ~= "" then - 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 - full_prompt = full_prompt .. "\n\nSELECTED CODE (" .. location .. "):\n```" .. (sel.language or "") .. "\n" - full_prompt = full_prompt .. sel.text .. "\n```" - end - - if file_context ~= "" then - full_prompt = full_prompt .. "\n\nATTACHED FILE CONTENTS (please analyze these):" .. file_context - end - - -- Also add current file if no files were explicitly attached and no selection - if file_count == 0 and not state.selection_context and context.current_content and context.current_content ~= "" then - full_prompt = "USER QUESTION: " - .. question - .. "\n\n" - .. "CURRENT FILE (" - .. (context.current_file or "unknown") - .. "):\n```\n" - .. context.current_content - .. "\n```" - end - - -- Add exploration summary to prompt if available - if exploration_result then - full_prompt = full_prompt - .. "\n\nPROJECT EXPLORATION COMPLETE: " - .. exploration_result.total_files - .. " files analyzed. " - .. "Project type: " - .. exploration_result.project.language - .. " (" - .. (exploration_result.project.framework or exploration_result.project.type) - .. ")" - end - - local request_context = { - file_content = file_context ~= "" and file_context or context.current_content, - language = context.language, - prompt_type = prompt_type, - file_path = context.current_file, - } - - client.generate(full_prompt, request_context, function(response, err) - -- Remove loading indicator - if state.output_buf and vim.api.nvim_buf_is_valid(state.output_buf) then - vim.bo[state.output_buf].modifiable = true - local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) - -- Remove last few lines (the thinking message) - local to_remove = 0 - for i = #lines, 1, -1 do - if lines[i]:match("Generating") or lines[i]:match("^[│└┌─]") or lines[i] == "" then - to_remove = to_remove + 1 - if lines[i]:match("┌") or to_remove >= 5 then - break - end - else - break - end - end - for _ = 1, math.min(to_remove, 5) do - table.remove(lines) - end - vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) - vim.bo[state.output_buf].modifiable = false - end - - if err then - append_to_output("❌ Error: " .. err, false) - return - end - - if response then - -- Add to history - table.insert(state.history, { role = "assistant", content = response }) - -- Display response - append_to_output(response, false) - else - append_to_output("❌ No response received", false) - end - - -- Focus back to input - M.focus_input() - end) -end - ---- Submit the question to LLM -function M.submit() - local question = get_input_text() - - if not question or question:match("^%s*$") then - utils.notify("Please enter a question", vim.log.levels.WARN) - M.focus_input() - return - end - - -- Detect intent from prompt - local ok_intent, intent_module = pcall(require, "codetyper.ask.intent") - local intent = nil - if ok_intent then - intent = intent_module.detect(question) - else - -- Fallback intent - intent = { - type = "ask", - confidence = 0.5, - needs_project_context = false, - needs_brain_context = true, - needs_exploration = false, - } - end - - -- Build context BEFORE clearing input (to preserve file references) - local context = build_context(intent) - local file_context, file_count = build_file_context() - - -- Build display message (without full file contents) - local display_question = question - if file_count > 0 then - display_question = question .. "\n📎 " .. file_count .. " file(s) attached" - end - -- Show detected intent if not standard ask - if intent.type ~= "ask" then - display_question = display_question .. "\n🎯 " .. intent.type:upper() .. " mode" - end - -- Show exploration indicator - if intent.needs_exploration then - display_question = display_question .. "\n🔍 Project exploration required" - end - - -- Add user message to output - append_to_output(display_question, true) - - -- Clear input and references AFTER building context - M.clear_input() - - -- Check if exploration is needed - if intent.needs_exploration then - local ok_explorer, explorer = pcall(require, "codetyper.ask.explorer") - if ok_explorer then - local root = utils.get_project_root() - if root then - -- Start exploration with logging - append_to_output("", false) - explorer.explore(root, append_exploration_log, function(exploration_result) - -- After exploration completes, continue with LLM request - continue_submit(question, intent, context, file_context, file_count, exploration_result) - end) - return - end - end - end - - -- No exploration needed, continue directly - continue_submit(question, intent, context, file_context, file_count, nil) -end - ---- Clear chat history -function M.clear_history() - state.history = {} - state.referenced_files = {} - - if state.output_buf and vim.api.nvim_buf_is_valid(state.output_buf) then - local header = { - "╔═════════════════════════════════╗", - "║ [ASK MODE] Q&A Chat ║", - "╠═════════════════════════════════╣", - "║ Ask about code or concepts ║", - "║ ║", - "║ @ → attach file ║", - "║ C-Enter → send ║", - "║ C-n → new chat ║", - "║ C-f → add current file ║", - "║ L → toggle LLM logs ║", - "║ :CoderType → switch mode ║", - "║ q → close │ K/J → jump ║", - "╚═════════════════════════════════╝", - "", - } - vim.bo[state.output_buf].modifiable = true - vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, header) - vim.bo[state.output_buf].modifiable = false - end - - utils.notify("Chat history cleared") -end - ---- Start a new chat (clears history and input) -function M.new_chat() - -- Clear the input - M.clear_input() - -- Clear the history - M.clear_history() - -- Focus the input - M.focus_input() - utils.notify("Started new chat") -end - ---- Include current file context in input -function M.include_file_context() - local context = build_context() - - if not context.current_file then - utils.notify("No file context available", vim.log.levels.WARN) - return - end - - local filename = vim.fn.fnamemodify(context.current_file, ":t") - M.add_file_reference(context.current_file, filename) -end - ---- Copy last assistant response to clipboard -function M.copy_last_response() - for i = #state.history, 1, -1 do - if state.history[i].role == "assistant" then - vim.fn.setreg("+", state.history[i].content) - utils.notify("Response copied to clipboard") - return - end - end - utils.notify("No response to copy", vim.log.levels.WARN) -end - ---- Show chat mode switcher modal -function M.show_chat_switcher() - local switcher = require("codetyper.chat_switcher") - switcher.show() -end ---- Check if ask panel is open (validates window state) ----@return boolean -function M.is_open() - -- Verify windows are actually valid, not just the flag - if state.is_open then - local output_valid = state.output_win and vim.api.nvim_win_is_valid(state.output_win) - local input_valid = state.input_win and vim.api.nvim_win_is_valid(state.input_win) - - -- If either window is invalid, reset the state - if not output_valid or not input_valid then - state.is_open = false - state.output_win = nil - state.input_win = nil - state.target_width = nil - -- Clean up autocmd - if ask_augroup then - pcall(vim.api.nvim_del_augroup_by_id, ask_augroup) - ask_augroup = nil - end - end - end - - return state.is_open -end - ---- Get chat history ----@return table History -function M.get_history() - return state.history -end - ---- Toggle LLM log visibility in chat ----@return boolean New state -function M.toggle_logs() - state.show_logs = not state.show_logs - utils.notify("LLM logs " .. (state.show_logs and "enabled" or "disabled")) - return state.show_logs -end - ---- Check if logs are enabled ----@return boolean -function M.logs_enabled() - return state.show_logs -end - -return M diff --git a/lua/codetyper/features/ask/explorer.lua b/lua/codetyper/features/ask/explorer.lua deleted file mode 100644 index 8c199ce..0000000 --- a/lua/codetyper/features/ask/explorer.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/features/ask/intent.lua b/lua/codetyper/features/ask/intent.lua deleted file mode 100644 index f56340c..0000000 --- a/lua/codetyper/features/ask/intent.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/features/completion/inject.lua b/lua/codetyper/features/completion/inject.lua deleted file mode 100644 index cb82452..0000000 --- a/lua/codetyper/features/completion/inject.lua +++ /dev/null @@ -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 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 diff --git a/lua/codetyper/init.lua b/lua/codetyper/init.lua index 191dbe0..5d9d32c 100644 --- a/lua/codetyper/init.lua +++ b/lua/codetyper/init.lua @@ -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 diff --git a/lua/codetyper/params/agents/bash.lua b/lua/codetyper/params/agents/bash.lua deleted file mode 100644 index 5a81e86..0000000 --- a/lua/codetyper/params/agents/bash.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/confidence.lua b/lua/codetyper/params/agents/confidence.lua deleted file mode 100644 index 2cb192b..0000000 --- a/lua/codetyper/params/agents/confidence.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/conflict.lua b/lua/codetyper/params/agents/conflict.lua deleted file mode 100644 index 5c77656..0000000 --- a/lua/codetyper/params/agents/conflict.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/context.lua b/lua/codetyper/params/agents/context.lua deleted file mode 100644 index cfa4acb..0000000 --- a/lua/codetyper/params/agents/context.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/edit.lua b/lua/codetyper/params/agents/edit.lua deleted file mode 100644 index 3f353cd..0000000 --- a/lua/codetyper/params/agents/edit.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/grep.lua b/lua/codetyper/params/agents/grep.lua deleted file mode 100644 index b977a46..0000000 --- a/lua/codetyper/params/agents/grep.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/intent.lua b/lua/codetyper/params/agents/intent.lua deleted file mode 100644 index 4a5a411..0000000 --- a/lua/codetyper/params/agents/intent.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/languages.lua b/lua/codetyper/params/agents/languages.lua deleted file mode 100644 index 7aee62c..0000000 --- a/lua/codetyper/params/agents/languages.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/linter.lua b/lua/codetyper/params/agents/linter.lua deleted file mode 100644 index 88644b1..0000000 --- a/lua/codetyper/params/agents/linter.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/logs.lua b/lua/codetyper/params/agents/logs.lua deleted file mode 100644 index 6346f73..0000000 --- a/lua/codetyper/params/agents/logs.lua +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lua/codetyper/params/agents/parser.lua b/lua/codetyper/params/agents/parser.lua deleted file mode 100644 index d014634..0000000 --- a/lua/codetyper/params/agents/parser.lua +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lua/codetyper/params/agents/patch.lua b/lua/codetyper/params/agents/patch.lua deleted file mode 100644 index c328e0e..0000000 --- a/lua/codetyper/params/agents/patch.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/permissions.lua b/lua/codetyper/params/agents/permissions.lua deleted file mode 100644 index bf49d34..0000000 --- a/lua/codetyper/params/agents/permissions.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/scheduler.lua b/lua/codetyper/params/agents/scheduler.lua deleted file mode 100644 index 4936b17..0000000 --- a/lua/codetyper/params/agents/scheduler.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/scope.lua b/lua/codetyper/params/agents/scope.lua deleted file mode 100644 index aa15eae..0000000 --- a/lua/codetyper/params/agents/scope.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/search_replace.lua b/lua/codetyper/params/agents/search_replace.lua deleted file mode 100644 index 54ede10..0000000 --- a/lua/codetyper/params/agents/search_replace.lua +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lua/codetyper/params/agents/tools.lua b/lua/codetyper/params/agents/tools.lua deleted file mode 100644 index 94379dd..0000000 --- a/lua/codetyper/params/agents/tools.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/view.lua b/lua/codetyper/params/agents/view.lua deleted file mode 100644 index 64d2d4e..0000000 --- a/lua/codetyper/params/agents/view.lua +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lua/codetyper/params/agents/worker.lua b/lua/codetyper/params/agents/worker.lua deleted file mode 100644 index b1220a9..0000000 --- a/lua/codetyper/params/agents/worker.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/params/agents/write.lua b/lua/codetyper/params/agents/write.lua deleted file mode 100644 index 60a71b4..0000000 --- a/lua/codetyper/params/agents/write.lua +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lua/codetyper/prompts/agents/bash.lua b/lua/codetyper/prompts/agents/bash.lua deleted file mode 100644 index db10d83..0000000 --- a/lua/codetyper/prompts/agents/bash.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/diff.lua b/lua/codetyper/prompts/agents/diff.lua deleted file mode 100644 index 5c9bb5e..0000000 --- a/lua/codetyper/prompts/agents/diff.lua +++ /dev/null @@ -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/", "Keyword" }, - { " approve ", "Normal" }, - { "n/q/", "Keyword" }, - { " reject ", "Normal" }, - { "", "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 diff --git a/lua/codetyper/prompts/agents/edit.lua b/lua/codetyper/prompts/agents/edit.lua deleted file mode 100644 index 2603a83..0000000 --- a/lua/codetyper/prompts/agents/edit.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/grep.lua b/lua/codetyper/prompts/agents/grep.lua deleted file mode 100644 index 9713c41..0000000 --- a/lua/codetyper/prompts/agents/grep.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/init.lua b/lua/codetyper/prompts/agents/init.lua deleted file mode 100644 index 2b98333..0000000 --- a/lua/codetyper/prompts/agents/init.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/intent.lua b/lua/codetyper/prompts/agents/intent.lua deleted file mode 100644 index 0c2dc15..0000000 --- a/lua/codetyper/prompts/agents/intent.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/linter.lua b/lua/codetyper/prompts/agents/linter.lua deleted file mode 100644 index ef5ec09..0000000 --- a/lua/codetyper/prompts/agents/linter.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/loop.lua b/lua/codetyper/prompts/agents/loop.lua deleted file mode 100644 index 39bb09e..0000000 --- a/lua/codetyper/prompts/agents/loop.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/modal.lua b/lua/codetyper/prompts/agents/modal.lua deleted file mode 100644 index 9d205c5..0000000 --- a/lua/codetyper/prompts/agents/modal.lua +++ /dev/null @@ -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 to run a command, or 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 diff --git a/lua/codetyper/prompts/agents/personas.lua b/lua/codetyper/prompts/agents/personas.lua deleted file mode 100644 index 4c8255f..0000000 --- a/lua/codetyper/prompts/agents/personas.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/scheduler.lua b/lua/codetyper/prompts/agents/scheduler.lua deleted file mode 100644 index 92558bb..0000000 --- a/lua/codetyper/prompts/agents/scheduler.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/templates.lua b/lua/codetyper/prompts/agents/templates.lua deleted file mode 100644 index 9acf785..0000000 --- a/lua/codetyper/prompts/agents/templates.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/tools.lua b/lua/codetyper/prompts/agents/tools.lua deleted file mode 100644 index 4a7d5eb..0000000 --- a/lua/codetyper/prompts/agents/tools.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/agents/view.lua b/lua/codetyper/prompts/agents/view.lua deleted file mode 100644 index 7f38e4c..0000000 --- a/lua/codetyper/prompts/agents/view.lua +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lua/codetyper/prompts/agents/write.lua b/lua/codetyper/prompts/agents/write.lua deleted file mode 100644 index d15b87b..0000000 --- a/lua/codetyper/prompts/agents/write.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/ask.lua b/lua/codetyper/prompts/ask.lua deleted file mode 100644 index 37fe450..0000000 --- a/lua/codetyper/prompts/ask.lua +++ /dev/null @@ -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 diff --git a/lua/codetyper/prompts/init.lua b/lua/codetyper/prompts/init.lua index eeb22bc..efc707f 100644 --- a/lua/codetyper/prompts/init.lua +++ b/lua/codetyper/prompts/init.lua @@ -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) diff --git a/plugin/codetyper.lua b/plugin/codetyper.lua index a91d109..ca8de77 100644 --- a/plugin/codetyper.lua +++ b/plugin/codetyper.lua @@ -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" }) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index f98e1c4..5be0d81 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -44,5 +44,4 @@ require("codetyper").setup({ enabled = false, -- Disable scheduler during tests }, auto_gitignore = false, - auto_open_ask = false, }) diff --git a/tests/spec/agent_tools_spec.lua b/tests/spec/agent_tools_spec.lua deleted file mode 100644 index b7f31f5..0000000 --- a/tests/spec/agent_tools_spec.lua +++ /dev/null @@ -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) diff --git a/tests/spec/agentic_spec.lua b/tests/spec/agentic_spec.lua deleted file mode 100644 index 8597694..0000000 --- a/tests/spec/agentic_spec.lua +++ /dev/null @@ -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) diff --git a/tests/spec/ask_intent_spec.lua b/tests/spec/ask_intent_spec.lua deleted file mode 100644 index 283e279..0000000 --- a/tests/spec/ask_intent_spec.lua +++ /dev/null @@ -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)