From e534d607d871548406ca358216ae93e0d30efc20 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Mon, 16 Feb 2026 11:12:22 -0500 Subject: [PATCH] fixing unable files --- lua/codetyper/adapters/nvim/commands.lua | 1527 +++++++++-------- lua/codetyper/adapters/nvim/ui/chat.lua | 907 ++++++++++ .../adapters/nvim/ui/context_modal.lua | 381 ++++ .../adapters/nvim/ui/diff_review.lua | 386 +++++ lua/codetyper/adapters/nvim/ui/logs.lua | 380 ++++ lua/codetyper/adapters/nvim/ui/logs_panel.lua | 382 +++++ lua/codetyper/adapters/nvim/ui/switcher.lua | 44 + lua/codetyper/config/credentials.lua | 12 +- lua/codetyper/core/diff/conflict.lua | 1052 ++++++++++++ lua/codetyper/core/diff/diff.lua | 320 ++++ lua/codetyper/core/diff/patch.lua | 1098 ++++++++++++ lua/codetyper/core/diff/search_replace.lua | 572 ++++++ lua/codetyper/core/intent/init.lua | 117 ++ lua/codetyper/core/llm/confidence.lua | 275 +++ lua/codetyper/core/llm/copilot.lua | 10 +- lua/codetyper/core/llm/gemini.lua | 4 +- lua/codetyper/core/llm/ollama.lua | 4 +- lua/codetyper/core/llm/openai.lua | 6 +- lua/codetyper/core/scheduler/executor.lua | 616 +++++++ lua/codetyper/core/scheduler/loop.lua | 381 ++++ lua/codetyper/core/scheduler/resume.lua | 155 ++ lua/codetyper/core/scheduler/scheduler.lua | 756 ++++++++ lua/codetyper/core/scheduler/worker.lua | 1034 +++++++++++ lua/codetyper/core/scope/init.lua | 431 +++++ lua/codetyper/params/agents/bash.lua | 35 + lua/codetyper/params/agents/confidence.lua | 40 + lua/codetyper/params/agents/conflict.lua | 33 + lua/codetyper/params/agents/context.lua | 48 + lua/codetyper/params/agents/edit.lua | 33 + lua/codetyper/params/agents/grep.lua | 10 + lua/codetyper/params/agents/intent.lua | 161 ++ lua/codetyper/params/agents/languages.lua | 87 + lua/codetyper/params/agents/linter.lua | 15 + lua/codetyper/params/agents/logs.lua | 36 + lua/codetyper/params/agents/parser.lua | 15 + lua/codetyper/params/agents/patch.lua | 12 + lua/codetyper/params/agents/permissions.lua | 47 + lua/codetyper/params/agents/scheduler.lua | 14 + lua/codetyper/params/agents/scope.lua | 72 + .../params/agents/search_replace.lua | 11 + lua/codetyper/params/agents/tools.lua | 147 ++ lua/codetyper/params/agents/view.lua | 37 + lua/codetyper/params/agents/worker.lua | 30 + lua/codetyper/params/agents/write.lua | 30 + lua/codetyper/parser.lua | 346 ++-- lua/codetyper/prompts/agents/bash.lua | 16 + lua/codetyper/prompts/agents/diff.lua | 66 + lua/codetyper/prompts/agents/edit.lua | 14 + lua/codetyper/prompts/agents/grep.lua | 41 + lua/codetyper/prompts/agents/init.lua | 141 ++ lua/codetyper/prompts/agents/intent.lua | 53 + lua/codetyper/prompts/agents/linter.lua | 13 + lua/codetyper/prompts/agents/loop.lua | 55 + lua/codetyper/prompts/agents/modal.lua | 14 + lua/codetyper/prompts/agents/personas.lua | 58 + lua/codetyper/prompts/agents/scheduler.lua | 12 + lua/codetyper/prompts/agents/templates.lua | 51 + lua/codetyper/prompts/agents/tools.lua | 18 + lua/codetyper/prompts/agents/view.lua | 11 + lua/codetyper/prompts/agents/write.lua | 8 + lua/codetyper/prompts/ask.lua | 177 ++ lua/codetyper/prompts/init.lua | 2 +- 62 files changed, 11915 insertions(+), 944 deletions(-) create mode 100644 lua/codetyper/adapters/nvim/ui/chat.lua create mode 100644 lua/codetyper/adapters/nvim/ui/context_modal.lua create mode 100644 lua/codetyper/adapters/nvim/ui/diff_review.lua create mode 100644 lua/codetyper/adapters/nvim/ui/logs.lua create mode 100644 lua/codetyper/adapters/nvim/ui/logs_panel.lua create mode 100644 lua/codetyper/adapters/nvim/ui/switcher.lua create mode 100644 lua/codetyper/core/diff/conflict.lua create mode 100644 lua/codetyper/core/diff/diff.lua create mode 100644 lua/codetyper/core/diff/patch.lua create mode 100644 lua/codetyper/core/diff/search_replace.lua create mode 100644 lua/codetyper/core/intent/init.lua create mode 100644 lua/codetyper/core/llm/confidence.lua create mode 100644 lua/codetyper/core/scheduler/executor.lua create mode 100644 lua/codetyper/core/scheduler/loop.lua create mode 100644 lua/codetyper/core/scheduler/resume.lua create mode 100644 lua/codetyper/core/scheduler/scheduler.lua create mode 100644 lua/codetyper/core/scheduler/worker.lua create mode 100644 lua/codetyper/core/scope/init.lua create mode 100644 lua/codetyper/params/agents/bash.lua create mode 100644 lua/codetyper/params/agents/confidence.lua create mode 100644 lua/codetyper/params/agents/conflict.lua create mode 100644 lua/codetyper/params/agents/context.lua create mode 100644 lua/codetyper/params/agents/edit.lua create mode 100644 lua/codetyper/params/agents/grep.lua create mode 100644 lua/codetyper/params/agents/intent.lua create mode 100644 lua/codetyper/params/agents/languages.lua create mode 100644 lua/codetyper/params/agents/linter.lua create mode 100644 lua/codetyper/params/agents/logs.lua create mode 100644 lua/codetyper/params/agents/parser.lua create mode 100644 lua/codetyper/params/agents/patch.lua create mode 100644 lua/codetyper/params/agents/permissions.lua create mode 100644 lua/codetyper/params/agents/scheduler.lua create mode 100644 lua/codetyper/params/agents/scope.lua create mode 100644 lua/codetyper/params/agents/search_replace.lua create mode 100644 lua/codetyper/params/agents/tools.lua create mode 100644 lua/codetyper/params/agents/view.lua create mode 100644 lua/codetyper/params/agents/worker.lua create mode 100644 lua/codetyper/params/agents/write.lua create mode 100644 lua/codetyper/prompts/agents/bash.lua create mode 100644 lua/codetyper/prompts/agents/diff.lua create mode 100644 lua/codetyper/prompts/agents/edit.lua create mode 100644 lua/codetyper/prompts/agents/grep.lua create mode 100644 lua/codetyper/prompts/agents/init.lua create mode 100644 lua/codetyper/prompts/agents/intent.lua create mode 100644 lua/codetyper/prompts/agents/linter.lua create mode 100644 lua/codetyper/prompts/agents/loop.lua create mode 100644 lua/codetyper/prompts/agents/modal.lua create mode 100644 lua/codetyper/prompts/agents/personas.lua create mode 100644 lua/codetyper/prompts/agents/scheduler.lua create mode 100644 lua/codetyper/prompts/agents/templates.lua create mode 100644 lua/codetyper/prompts/agents/tools.lua create mode 100644 lua/codetyper/prompts/agents/view.lua create mode 100644 lua/codetyper/prompts/agents/write.lua create mode 100644 lua/codetyper/prompts/ask.lua diff --git a/lua/codetyper/adapters/nvim/commands.lua b/lua/codetyper/adapters/nvim/commands.lua index 70bcfdb..6ed6851 100644 --- a/lua/codetyper/adapters/nvim/commands.lua +++ b/lua/codetyper/adapters/nvim/commands.lua @@ -8,84 +8,84 @@ local window = require("codetyper.adapters.nvim.windows") --- Open coder view for current file or select one ---@param opts? table Command options local function cmd_open(opts) - opts = opts or {} + opts = opts or {} - local current_file = vim.fn.expand("%:p") + local current_file = vim.fn.expand("%:p") - -- If no file is open, prompt user to select one - if current_file == "" or vim.bo.buftype ~= "" then - -- Use telescope or vim.ui.select to pick a file - if pcall(require, "telescope") then - require("telescope.builtin").find_files({ - prompt_title = "Select file for Coder", - attach_mappings = function(prompt_bufnr, map) - local actions = require("telescope.actions") - local action_state = require("telescope.actions.state") + -- If no file is open, prompt user to select one + if current_file == "" or vim.bo.buftype ~= "" then + -- Use telescope or vim.ui.select to pick a file + if pcall(require, "telescope") then + require("telescope.builtin").find_files({ + prompt_title = "Select file for Coder", + 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 target_path = selection.path or selection[1] - local coder_path = utils.get_coder_path(target_path) - window.open_split(target_path, coder_path) - end - end) - return true - end, - }) - else - -- Fallback to input prompt - vim.ui.input({ prompt = "Enter file path: " }, function(input) - if input and input ~= "" then - local target_path = vim.fn.fnamemodify(input, ":p") - local coder_path = utils.get_coder_path(target_path) - window.open_split(target_path, coder_path) - end - end) - end - return - end + actions.select_default:replace(function() + actions.close(prompt_bufnr) + local selection = action_state.get_selected_entry() + if selection then + local target_path = selection.path or selection[1] + local coder_path = utils.get_coder_path(target_path) + window.open_split(target_path, coder_path) + end + end) + return true + end, + }) + else + -- Fallback to input prompt + vim.ui.input({ prompt = "Enter file path: " }, function(input) + if input and input ~= "" then + local target_path = vim.fn.fnamemodify(input, ":p") + local coder_path = utils.get_coder_path(target_path) + window.open_split(target_path, coder_path) + end + end) + end + return + end - local target_path, coder_path + local target_path, coder_path - -- Check if current file is a coder file - if utils.is_coder_file(current_file) then - coder_path = current_file - target_path = utils.get_target_path(current_file) - else - target_path = current_file - coder_path = utils.get_coder_path(current_file) - end + -- Check if current file is a coder file + if utils.is_coder_file(current_file) then + coder_path = current_file + target_path = utils.get_target_path(current_file) + else + target_path = current_file + coder_path = utils.get_coder_path(current_file) + end - window.open_split(target_path, coder_path) + window.open_split(target_path, coder_path) end --- Close coder view local function cmd_close() - window.close_split() + window.close_split() end --- Toggle coder view local function cmd_toggle() - local current_file = vim.fn.expand("%:p") + local current_file = vim.fn.expand("%:p") - if current_file == "" then - utils.notify("No file in current buffer", vim.log.levels.WARN) - return - end + if current_file == "" then + utils.notify("No file in current buffer", vim.log.levels.WARN) + return + end - local target_path, coder_path + local target_path, coder_path - if utils.is_coder_file(current_file) then - coder_path = current_file - target_path = utils.get_target_path(current_file) - else - target_path = current_file - coder_path = utils.get_coder_path(current_file) - end + if utils.is_coder_file(current_file) then + coder_path = current_file + target_path = utils.get_target_path(current_file) + else + target_path = current_file + coder_path = utils.get_coder_path(current_file) + end - window.toggle_split(target_path, coder_path) + window.toggle_split(target_path, coder_path) end --- Build enhanced user prompt with context @@ -93,194 +93,194 @@ end ---@param context table Context information ---@return string Enhanced prompt local function build_user_prompt(clean_prompt, context) - local enhanced = "TASK: " .. clean_prompt .. "\n\n" - - enhanced = enhanced .. "REQUIREMENTS:\n" - enhanced = enhanced .. "- Generate ONLY " .. (context.language or "code") .. " code\n" - enhanced = enhanced .. "- NO markdown code blocks (no ```)\n" - enhanced = enhanced .. "- NO explanations or comments about what you did\n" - enhanced = enhanced .. "- Match the coding style of the existing file exactly\n" - enhanced = enhanced .. "- Output must be ready to insert directly into the file\n" - - return enhanced + local enhanced = "TASK: " .. clean_prompt .. "\n\n" + + enhanced = enhanced .. "REQUIREMENTS:\n" + enhanced = enhanced .. "- Generate ONLY " .. (context.language or "code") .. " code\n" + enhanced = enhanced .. "- NO markdown code blocks (no ```)\n" + enhanced = enhanced .. "- NO explanations or comments about what you did\n" + enhanced = enhanced .. "- Match the coding style of the existing file exactly\n" + enhanced = enhanced .. "- Output must be ready to insert directly into the file\n" + + return enhanced end --- Process prompt at cursor and generate code local function cmd_process() - local parser = require("codetyper.parser") - local llm = require("codetyper.core.llm") + local parser = require("codetyper.parser") + local llm = require("codetyper.core.llm") - local bufnr = vim.api.nvim_get_current_buf() - local current_file = vim.fn.expand("%:p") + local bufnr = vim.api.nvim_get_current_buf() + local current_file = vim.fn.expand("%:p") - if not utils.is_coder_file(current_file) then - utils.notify("Not a coder file. Use *.coder.* files", vim.log.levels.WARN) - return - end + if not utils.is_coder_file(current_file) then + utils.notify("Not a coder file. Use *.coder.* files", vim.log.levels.WARN) + return + end - local prompt = parser.get_last_prompt(bufnr) - if not prompt then - utils.notify("No prompt found. Use /@ your prompt @/", vim.log.levels.WARN) - return - end + local prompt = parser.get_last_prompt(bufnr) + if not prompt then + utils.notify("No prompt found. Use /@ your prompt @/", vim.log.levels.WARN) + return + end - local target_path = utils.get_target_path(current_file) - local prompt_type = parser.detect_prompt_type(prompt.content) - local context = llm.build_context(target_path, prompt_type) - local clean_prompt = parser.clean_prompt(prompt.content) - - -- Build enhanced prompt with explicit instructions - local enhanced_prompt = build_user_prompt(clean_prompt, context) + local target_path = utils.get_target_path(current_file) + local prompt_type = parser.detect_prompt_type(prompt.content) + local context = llm.build_context(target_path, prompt_type) + local clean_prompt = parser.clean_prompt(prompt.content) - utils.notify("Processing: " .. clean_prompt:sub(1, 50) .. "...", vim.log.levels.INFO) + -- Build enhanced prompt with explicit instructions + local enhanced_prompt = build_user_prompt(clean_prompt, context) - llm.generate(enhanced_prompt, context, function(response, err) - if err then - utils.notify("Generation failed: " .. err, vim.log.levels.ERROR) - return - end + utils.notify("Processing: " .. clean_prompt:sub(1, 50) .. "...", vim.log.levels.INFO) - if response then - -- Inject code into target file - local inject = require("codetyper.inject") - inject.inject_code(target_path, response, prompt_type) - utils.notify("Code generated and injected!", vim.log.levels.INFO) - end - end) + llm.generate(enhanced_prompt, context, function(response, err) + if err then + utils.notify("Generation failed: " .. err, vim.log.levels.ERROR) + return + end + + if response then + -- Inject code into target file + local inject = require("codetyper.inject") + inject.inject_code(target_path, response, prompt_type) + utils.notify("Code generated and injected!", vim.log.levels.INFO) + end + end) end --- Show plugin status local function cmd_status() - local codetyper = require("codetyper") - local config = codetyper.get_config() - local tree = require("codetyper.support.tree") + local codetyper = require("codetyper") + local config = codetyper.get_config() + local tree = require("codetyper.support.tree") - local stats = tree.get_stats() + local stats = tree.get_stats() - local status = { - "Codetyper.nvim Status", - "====================", - "", - "Provider: " .. config.llm.provider, - } + local status = { + "Codetyper.nvim Status", + "====================", + "", + "Provider: " .. config.llm.provider, + } - if config.llm.provider == "ollama" then - table.insert(status, "Ollama Host: " .. config.llm.ollama.host) - table.insert(status, "Ollama Model: " .. config.llm.ollama.model) - elseif config.llm.provider == "openai" then - local has_key = (config.llm.openai.api_key or vim.env.OPENAI_API_KEY) ~= nil - table.insert(status, "OpenAI API Key: " .. (has_key and "configured" or "NOT SET")) - table.insert(status, "OpenAI Model: " .. config.llm.openai.model) - elseif config.llm.provider == "gemini" then - local has_key = (config.llm.gemini.api_key or vim.env.GEMINI_API_KEY) ~= nil - table.insert(status, "Gemini API Key: " .. (has_key and "configured" or "NOT SET")) - table.insert(status, "Gemini Model: " .. config.llm.gemini.model) - elseif config.llm.provider == "copilot" then - table.insert(status, "Copilot Model: " .. config.llm.copilot.model) - end + if config.llm.provider == "ollama" then + table.insert(status, "Ollama Host: " .. config.llm.ollama.host) + table.insert(status, "Ollama Model: " .. config.llm.ollama.model) + elseif config.llm.provider == "openai" then + local has_key = (config.llm.openai.api_key or vim.env.OPENAI_API_KEY) ~= nil + table.insert(status, "OpenAI API Key: " .. (has_key and "configured" or "NOT SET")) + table.insert(status, "OpenAI Model: " .. config.llm.openai.model) + elseif config.llm.provider == "gemini" then + local has_key = (config.llm.gemini.api_key or vim.env.GEMINI_API_KEY) ~= nil + table.insert(status, "Gemini API Key: " .. (has_key and "configured" or "NOT SET")) + table.insert(status, "Gemini Model: " .. config.llm.gemini.model) + elseif config.llm.provider == "copilot" then + table.insert(status, "Copilot Model: " .. config.llm.copilot.model) + end - table.insert(status, "") - table.insert(status, "Window Position: " .. config.window.position) - table.insert(status, "Window Width: " .. tostring(config.window.width * 100) .. "%") - table.insert(status, "") - table.insert(status, "View Open: " .. (window.is_open() and "yes" or "no")) - table.insert(status, "") - table.insert(status, "Project Stats:") - table.insert(status, " Files: " .. stats.files) - table.insert(status, " Directories: " .. stats.directories) - table.insert(status, " Tree Log: " .. (tree.get_tree_log_path() or "N/A")) + table.insert(status, "") + table.insert(status, "Window Position: " .. config.window.position) + table.insert(status, "Window Width: " .. tostring(config.window.width * 100) .. "%") + table.insert(status, "") + table.insert(status, "View Open: " .. (window.is_open() and "yes" or "no")) + table.insert(status, "") + table.insert(status, "Project Stats:") + table.insert(status, " Files: " .. stats.files) + table.insert(status, " Directories: " .. stats.directories) + table.insert(status, " Tree Log: " .. (tree.get_tree_log_path() or "N/A")) - utils.notify(table.concat(status, "\n")) + utils.notify(table.concat(status, "\n")) end --- Refresh tree.log manually local function cmd_tree() - local tree = require("codetyper.support.tree") - if tree.update_tree_log() then - utils.notify("Tree log updated: " .. tree.get_tree_log_path()) - else - utils.notify("Failed to update tree log", vim.log.levels.ERROR) - end + local tree = require("codetyper.support.tree") + if tree.update_tree_log() then + utils.notify("Tree log updated: " .. tree.get_tree_log_path()) + else + utils.notify("Failed to update tree log", vim.log.levels.ERROR) + end end --- Open tree.log file local function cmd_tree_view() - local tree = require("codetyper.support.tree") - local tree_log_path = tree.get_tree_log_path() + local tree = require("codetyper.support.tree") + local tree_log_path = tree.get_tree_log_path() - if not tree_log_path then - utils.notify("Could not find tree.log", vim.log.levels.WARN) - return - end + if not tree_log_path then + utils.notify("Could not find tree.log", vim.log.levels.WARN) + return + end - -- Ensure tree is up to date - tree.update_tree_log() + -- Ensure tree is up to date + tree.update_tree_log() - -- Open in a new split - vim.cmd("vsplit " .. vim.fn.fnameescape(tree_log_path)) - vim.bo.readonly = true - vim.bo.modifiable = false + -- Open in a new split + vim.cmd("vsplit " .. vim.fn.fnameescape(tree_log_path)) + vim.bo.readonly = true + vim.bo.modifiable = false end --- Reset processed prompts to allow re-processing local function cmd_reset() - local autocmds = require("codetyper.adapters.nvim.autocmds") - autocmds.reset_processed() + local autocmds = require("codetyper.adapters.nvim.autocmds") + autocmds.reset_processed() end --- Force update gitignore local function cmd_gitignore() - local gitignore = require("codetyper.support.gitignore") - gitignore.force_update() + local gitignore = require("codetyper.support.gitignore") + gitignore.force_update() end --- Switch focus between coder and target windows local function cmd_focus() - if not window.is_open() then - utils.notify("Coder view not open", vim.log.levels.WARN) - return - end + if not window.is_open() then + utils.notify("Coder view not open", vim.log.levels.WARN) + return + end - local current_win = vim.api.nvim_get_current_win() - if current_win == window.get_coder_win() then - window.focus_target() - else - window.focus_coder() - end + local current_win = vim.api.nvim_get_current_win() + if current_win == window.get_coder_win() then + window.focus_target() + else + window.focus_coder() + end end --- Transform inline /@ @/ tags in current file --- Works on ANY file, not just .coder.* files local function cmd_transform() - local parser = require("codetyper.parser") - local autocmds = require("codetyper.adapters.nvim.autocmds") + local parser = require("codetyper.parser") + local autocmds = require("codetyper.adapters.nvim.autocmds") - local bufnr = vim.api.nvim_get_current_buf() - local filepath = vim.fn.expand("%:p") + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.fn.expand("%:p") - if filepath == "" then - utils.notify("No file in current buffer", vim.log.levels.WARN) - return - end + if filepath == "" then + utils.notify("No file in current buffer", vim.log.levels.WARN) + return + end - -- Find all prompts in the current buffer - local prompts = parser.find_prompts_in_buffer(bufnr) + -- Find all prompts in the current buffer + local prompts = parser.find_prompts_in_buffer(bufnr) - if #prompts == 0 then - utils.notify("No /@ @/ tags found in current file", vim.log.levels.INFO) - return - end + if #prompts == 0 then + utils.notify("No /@ @/ tags found in current file", vim.log.levels.INFO) + return + end - utils.notify("Transforming " .. #prompts .. " prompt(s)...", vim.log.levels.INFO) + utils.notify("Transforming " .. #prompts .. " prompt(s)...", vim.log.levels.INFO) - utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO) + utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO) - -- Reset processed prompts tracking so we can re-process them (silent mode) - autocmds.reset_processed(bufnr, true) + -- Reset processed prompts tracking so we can re-process them (silent mode) + autocmds.reset_processed(bufnr, true) - -- Use the same processing logic as automatic mode - -- This ensures intent detection, scope resolution, and all other logic is identical - autocmds.check_all_prompts() + -- Use the same processing logic as automatic mode + -- This ensures intent detection, scope resolution, and all other logic is identical + autocmds.check_all_prompts() end --- Transform prompts within a line range (for visual selection) @@ -288,687 +288,710 @@ end ---@param start_line number Start line (1-indexed) ---@param end_line number End line (1-indexed) local function cmd_transform_range(start_line, end_line) - local parser = require("codetyper.parser") - local autocmds = require("codetyper.adapters.nvim.autocmds") + local parser = require("codetyper.parser") + local autocmds = require("codetyper.adapters.nvim.autocmds") - local bufnr = vim.api.nvim_get_current_buf() - local filepath = vim.fn.expand("%:p") + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.fn.expand("%:p") - if filepath == "" then - utils.notify("No file in current buffer", vim.log.levels.WARN) - return - end + if filepath == "" then + utils.notify("No file in current buffer", vim.log.levels.WARN) + return + end - -- Find all prompts in the current buffer - local all_prompts = parser.find_prompts_in_buffer(bufnr) + -- Find all prompts in the current buffer + local all_prompts = parser.find_prompts_in_buffer(bufnr) - -- Filter prompts that are within the selected range - local prompts = {} - for _, prompt in ipairs(all_prompts) do - if prompt.start_line >= start_line and prompt.end_line <= end_line then - table.insert(prompts, prompt) - end - end + -- Filter prompts that are within the selected range + local prompts = {} + for _, prompt in ipairs(all_prompts) do + if prompt.start_line >= start_line and prompt.end_line <= end_line then + table.insert(prompts, prompt) + end + end - if #prompts == 0 then - utils.notify("No /@ @/ tags found in selection (lines " .. start_line .. "-" .. end_line .. ")", vim.log.levels.INFO) - return - end + if #prompts == 0 then + utils.notify( + "No /@ @/ tags found in selection (lines " .. start_line .. "-" .. end_line .. ")", + vim.log.levels.INFO + ) + return + end - utils.notify("Transforming " .. #prompts .. " prompt(s)...", 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 - autocmds.process_single_prompt(bufnr, prompt, filepath, true) - end + -- Process each prompt using the same logic as automatic mode (skip processed check for manual mode) + for _, prompt in ipairs(prompts) do + autocmds.process_single_prompt(bufnr, prompt, filepath, true) + end end --- Command wrapper for visual selection transform local function cmd_transform_visual() - -- Get visual selection marks - local start_line = vim.fn.line("'<") - local end_line = vim.fn.line("'>") - cmd_transform_range(start_line, end_line) + -- Get visual selection marks + local start_line = vim.fn.line("'<") + local end_line = vim.fn.line("'>") + cmd_transform_range(start_line, end_line) end --- Index the entire project local function cmd_index_project() - local indexer = require("codetyper.features.indexer") + local indexer = require("codetyper.features.indexer") - utils.notify("Indexing project...", vim.log.levels.INFO) + utils.notify("Indexing project...", vim.log.levels.INFO) - indexer.index_project(function(index) - if index then - local msg = string.format( - "Indexed: %d files, %d functions, %d classes, %d exports", - index.stats.files, - index.stats.functions, - index.stats.classes, - index.stats.exports - ) - utils.notify(msg, vim.log.levels.INFO) - else - utils.notify("Failed to index project", vim.log.levels.ERROR) - end - end) + indexer.index_project(function(index) + if index then + local msg = string.format( + "Indexed: %d files, %d functions, %d classes, %d exports", + index.stats.files, + index.stats.functions, + index.stats.classes, + index.stats.exports + ) + utils.notify(msg, vim.log.levels.INFO) + else + utils.notify("Failed to index project", vim.log.levels.ERROR) + end + end) end --- Show index status local function cmd_index_status() - local indexer = require("codetyper.features.indexer") - local memory = require("codetyper.features.indexer.memory") + local indexer = require("codetyper.features.indexer") + local memory = require("codetyper.features.indexer.memory") - local status = indexer.get_status() - local mem_stats = memory.get_stats() + local status = indexer.get_status() + local mem_stats = memory.get_stats() - local lines = { - "Project Index Status", - "====================", - "", - } + local lines = { + "Project Index Status", + "====================", + "", + } - if status.indexed then - table.insert(lines, "Status: Indexed") - table.insert(lines, "Project Type: " .. (status.project_type or "unknown")) - table.insert(lines, "Last Indexed: " .. os.date("%Y-%m-%d %H:%M:%S", status.last_indexed)) - table.insert(lines, "") - table.insert(lines, "Stats:") - table.insert(lines, " Files: " .. (status.stats.files or 0)) - table.insert(lines, " Functions: " .. (status.stats.functions or 0)) - table.insert(lines, " Classes: " .. (status.stats.classes or 0)) - table.insert(lines, " Exports: " .. (status.stats.exports or 0)) - else - table.insert(lines, "Status: Not indexed") - table.insert(lines, "Run :CoderIndexProject to index") - end + if status.indexed then + table.insert(lines, "Status: Indexed") + table.insert(lines, "Project Type: " .. (status.project_type or "unknown")) + table.insert(lines, "Last Indexed: " .. os.date("%Y-%m-%d %H:%M:%S", status.last_indexed)) + table.insert(lines, "") + table.insert(lines, "Stats:") + table.insert(lines, " Files: " .. (status.stats.files or 0)) + table.insert(lines, " Functions: " .. (status.stats.functions or 0)) + table.insert(lines, " Classes: " .. (status.stats.classes or 0)) + table.insert(lines, " Exports: " .. (status.stats.exports or 0)) + else + table.insert(lines, "Status: Not indexed") + table.insert(lines, "Run :CoderIndexProject to index") + end - table.insert(lines, "") - table.insert(lines, "Memories:") - table.insert(lines, " Patterns: " .. mem_stats.patterns) - table.insert(lines, " Conventions: " .. mem_stats.conventions) - table.insert(lines, " Symbols: " .. mem_stats.symbols) + table.insert(lines, "") + table.insert(lines, "Memories:") + table.insert(lines, " Patterns: " .. mem_stats.patterns) + table.insert(lines, " Conventions: " .. mem_stats.conventions) + table.insert(lines, " Symbols: " .. mem_stats.symbols) - utils.notify(table.concat(lines, "\n")) + utils.notify(table.concat(lines, "\n")) end --- Show learned memories local function cmd_memories() - local memory = require("codetyper.features.indexer.memory") + local memory = require("codetyper.features.indexer.memory") - local all = memory.get_all() - local lines = { - "Learned Memories", - "================", - "", - "Patterns:", - } + local all = memory.get_all() + local lines = { + "Learned Memories", + "================", + "", + "Patterns:", + } - local pattern_count = 0 - for _, mem in pairs(all.patterns) do - pattern_count = pattern_count + 1 - if pattern_count <= 10 then - table.insert(lines, " - " .. (mem.content or ""):sub(1, 60)) - end - end - if pattern_count > 10 then - table.insert(lines, " ... and " .. (pattern_count - 10) .. " more") - elseif pattern_count == 0 then - table.insert(lines, " (none)") - end + local pattern_count = 0 + for _, mem in pairs(all.patterns) do + pattern_count = pattern_count + 1 + if pattern_count <= 10 then + table.insert(lines, " - " .. (mem.content or ""):sub(1, 60)) + end + end + if pattern_count > 10 then + table.insert(lines, " ... and " .. (pattern_count - 10) .. " more") + elseif pattern_count == 0 then + table.insert(lines, " (none)") + end - table.insert(lines, "") - table.insert(lines, "Conventions:") + table.insert(lines, "") + table.insert(lines, "Conventions:") - local conv_count = 0 - for _, mem in pairs(all.conventions) do - conv_count = conv_count + 1 - if conv_count <= 10 then - table.insert(lines, " - " .. (mem.content or ""):sub(1, 60)) - end - end - if conv_count > 10 then - table.insert(lines, " ... and " .. (conv_count - 10) .. " more") - elseif conv_count == 0 then - table.insert(lines, " (none)") - end + local conv_count = 0 + for _, mem in pairs(all.conventions) do + conv_count = conv_count + 1 + if conv_count <= 10 then + table.insert(lines, " - " .. (mem.content or ""):sub(1, 60)) + end + end + if conv_count > 10 then + table.insert(lines, " ... and " .. (conv_count - 10) .. " more") + elseif conv_count == 0 then + table.insert(lines, " (none)") + end - utils.notify(table.concat(lines, "\n")) + utils.notify(table.concat(lines, "\n")) end --- Clear memories ---@param pattern string|nil Optional pattern to match local function cmd_forget(pattern) - local memory = require("codetyper.features.indexer.memory") + local memory = require("codetyper.features.indexer.memory") - if not pattern or pattern == "" then - -- Confirm before clearing all - vim.ui.select({ "Yes", "No" }, { - prompt = "Clear all memories?", - }, function(choice) - if choice == "Yes" then - memory.clear() - utils.notify("All memories cleared", vim.log.levels.INFO) - end - end) - else - memory.clear(pattern) - utils.notify("Cleared memories matching: " .. pattern, vim.log.levels.INFO) - end + if not pattern or pattern == "" then + -- Confirm before clearing all + vim.ui.select({ "Yes", "No" }, { + prompt = "Clear all memories?", + }, function(choice) + if choice == "Yes" then + memory.clear() + utils.notify("All memories cleared", vim.log.levels.INFO) + end + end) + else + memory.clear(pattern) + utils.notify("Cleared memories matching: " .. pattern, vim.log.levels.INFO) + end end --- Transform a single prompt at cursor position local function cmd_transform_at_cursor() - local parser = require("codetyper.parser") - local autocmds = require("codetyper.adapters.nvim.autocmds") + local parser = require("codetyper.parser") + local autocmds = require("codetyper.adapters.nvim.autocmds") - local bufnr = vim.api.nvim_get_current_buf() - local filepath = vim.fn.expand("%:p") + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.fn.expand("%:p") - if filepath == "" then - utils.notify("No file in current buffer", vim.log.levels.WARN) - return - end + if filepath == "" then + utils.notify("No file in current buffer", vim.log.levels.WARN) + return + end - -- Find prompt at cursor - local prompt = parser.get_prompt_at_cursor(bufnr) + -- Find prompt at cursor + local prompt = parser.get_prompt_at_cursor(bufnr) - if not prompt then - utils.notify("No /@ @/ tag at cursor position", vim.log.levels.WARN) - return - end + if not prompt then + utils.notify("No /@ @/ tag at cursor position", vim.log.levels.WARN) + return + end - local clean_prompt = parser.clean_prompt(prompt.content) - utils.notify("Transforming: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO) + local clean_prompt = parser.clean_prompt(prompt.content) + 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) - autocmds.process_single_prompt(bufnr, prompt, filepath, true) + -- Use the same processing logic as automatic mode (skip processed check for manual mode) + autocmds.process_single_prompt(bufnr, prompt, filepath, true) end --- Main command dispatcher ---@param args table Command arguments --- Show LLM accuracy statistics local function cmd_llm_stats() - local llm = require("codetyper.core.llm") - local stats = llm.get_accuracy_stats() + local llm = require("codetyper.core.llm") + local stats = llm.get_accuracy_stats() - local lines = { - "LLM Provider Accuracy Statistics", - "================================", - "", - string.format("Ollama:"), - string.format(" Total requests: %d", stats.ollama.total), - string.format(" Correct: %d", stats.ollama.correct), - string.format(" Accuracy: %.1f%%", stats.ollama.accuracy * 100), - "", - string.format("Copilot:"), - string.format(" Total requests: %d", stats.copilot.total), - string.format(" Correct: %d", stats.copilot.correct), - string.format(" Accuracy: %.1f%%", stats.copilot.accuracy * 100), - "", - "Note: Smart selection prefers Ollama when brain memories", - "provide enough context. Accuracy improves over time via", - "pondering (verification with other LLMs).", - } + local lines = { + "LLM Provider Accuracy Statistics", + "================================", + "", + string.format("Ollama:"), + string.format(" Total requests: %d", stats.ollama.total), + string.format(" Correct: %d", stats.ollama.correct), + string.format(" Accuracy: %.1f%%", stats.ollama.accuracy * 100), + "", + string.format("Copilot:"), + string.format(" Total requests: %d", stats.copilot.total), + string.format(" Correct: %d", stats.copilot.correct), + string.format(" Accuracy: %.1f%%", stats.copilot.accuracy * 100), + "", + "Note: Smart selection prefers Ollama when brain memories", + "provide enough context. Accuracy improves over time via", + "pondering (verification with other LLMs).", + } - vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) + vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) end --- Report feedback on last LLM response ---@param was_good boolean Whether the response was good local function cmd_llm_feedback(was_good) - local llm = require("codetyper.core.llm") - -- Default to ollama for feedback - local provider = "ollama" + local llm = require("codetyper.core.llm") + -- Default to ollama for feedback + local provider = "ollama" - llm.report_feedback(provider, was_good) - local feedback_type = was_good and "positive" or "negative" - utils.notify(string.format("Reported %s feedback for %s", feedback_type, provider), vim.log.levels.INFO) + llm.report_feedback(provider, was_good) + local feedback_type = was_good and "positive" or "negative" + utils.notify(string.format("Reported %s feedback for %s", feedback_type, provider), vim.log.levels.INFO) end --- Reset LLM accuracy statistics local function cmd_llm_reset_stats() - local selector = require("codetyper.core.llm.selector") - selector.reset_accuracy_stats() - utils.notify("LLM accuracy statistics reset", vim.log.levels.INFO) + local selector = require("codetyper.core.llm.selector") + selector.reset_accuracy_stats() + utils.notify("LLM accuracy statistics reset", vim.log.levels.INFO) end local function coder_cmd(args) - local subcommand = args.fargs[1] or "toggle" + local subcommand = args.fargs[1] or "toggle" - local commands = { - open = cmd_open, - close = cmd_close, - toggle = cmd_toggle, - process = cmd_process, - status = cmd_status, - focus = cmd_focus, - tree = cmd_tree, - ["tree-view"] = cmd_tree_view, - reset = cmd_reset, - gitignore = cmd_gitignore, - transform = cmd_transform, - ["transform-cursor"] = cmd_transform_at_cursor, + local commands = { + open = cmd_open, + close = cmd_close, + toggle = cmd_toggle, + process = cmd_process, + status = cmd_status, + focus = cmd_focus, + tree = cmd_tree, + ["tree-view"] = cmd_tree_view, + reset = cmd_reset, + gitignore = cmd_gitignore, + transform = cmd_transform, + ["transform-cursor"] = cmd_transform_at_cursor, - ["index-project"] = cmd_index_project, - ["index-status"] = cmd_index_status, - memories = cmd_memories, - forget = function(args) - cmd_forget(args.fargs[2]) - end, - ["auto-toggle"] = function() - local preferences = require("codetyper.config.preferences") - preferences.toggle_auto_process() - end, - ["auto-set"] = function(args) - local preferences = require("codetyper.config.preferences") - local arg = (args[1] or ""):lower() - if arg == "auto" or arg == "automatic" or arg == "on" then - preferences.set_auto_process(true) - utils.notify("Set to automatic mode", vim.log.levels.INFO) - elseif arg == "manual" or arg == "off" then - preferences.set_auto_process(false) - utils.notify("Set to manual mode", vim.log.levels.INFO) - else - local auto = preferences.is_auto_process_enabled() - if auto == nil then - utils.notify("Mode not set yet (will ask on first prompt)", vim.log.levels.INFO) - else - local mode = auto and "automatic" or "manual" - utils.notify("Currently in " .. mode .. " mode", vim.log.levels.INFO) - end - end - end, - -- LLM smart selection commands - ["llm-stats"] = cmd_llm_stats, - ["llm-feedback-good"] = function() - cmd_llm_feedback(true) - end, - ["llm-feedback-bad"] = function() - cmd_llm_feedback(false) - end, - ["llm-reset-stats"] = cmd_llm_reset_stats, - -- Cost tracking commands - ["cost"] = function() - local cost = require("codetyper.core.cost") - cost.toggle() - end, - ["cost-clear"] = function() - local cost = require("codetyper.core.cost") - cost.clear() - end, - -- Credentials management commands - ["add-api-key"] = function() - local credentials = require("codetyper.credentials") - credentials.interactive_add() - end, - ["remove-api-key"] = function() - local credentials = require("codetyper.credentials") - credentials.interactive_remove() - end, - ["credentials"] = function() - local credentials = require("codetyper.credentials") - credentials.show_status() - end, - ["switch-provider"] = function() - local credentials = require("codetyper.credentials") - credentials.interactive_switch_provider() - end, - ["model"] = function(args) - local credentials = require("codetyper.credentials") - local codetyper = require("codetyper") - local config = codetyper.get_config() - local provider = config.llm.provider + ["index-project"] = cmd_index_project, + ["index-status"] = cmd_index_status, + memories = cmd_memories, + forget = function(args) + cmd_forget(args.fargs[2]) + end, + ["auto-toggle"] = function() + local preferences = require("codetyper.config.preferences") + preferences.toggle_auto_process() + end, + ["auto-set"] = function(args) + local preferences = require("codetyper.config.preferences") + local arg = (args[1] or ""):lower() + if arg == "auto" or arg == "automatic" or arg == "on" then + preferences.set_auto_process(true) + utils.notify("Set to automatic mode", vim.log.levels.INFO) + elseif arg == "manual" or arg == "off" then + preferences.set_auto_process(false) + utils.notify("Set to manual mode", vim.log.levels.INFO) + else + local auto = preferences.is_auto_process_enabled() + if auto == nil then + utils.notify("Mode not set yet (will ask on first prompt)", vim.log.levels.INFO) + else + local mode = auto and "automatic" or "manual" + utils.notify("Currently in " .. mode .. " mode", vim.log.levels.INFO) + end + end + end, + -- LLM smart selection commands + ["llm-stats"] = cmd_llm_stats, + ["llm-feedback-good"] = function() + cmd_llm_feedback(true) + end, + ["llm-feedback-bad"] = function() + cmd_llm_feedback(false) + end, + ["llm-reset-stats"] = cmd_llm_reset_stats, + -- Cost tracking commands + ["cost"] = function() + local cost = require("codetyper.core.cost") + cost.toggle() + end, + ["cost-clear"] = function() + local cost = require("codetyper.core.cost") + cost.clear() + end, + -- Credentials management commands + ["add-api-key"] = function() + local credentials = require("codetyper.config.credentials") + credentials.interactive_add() + end, + ["remove-api-key"] = function() + local credentials = require("codetyper.config.credentials") + credentials.interactive_remove() + end, + ["credentials"] = function() + local credentials = require("codetyper.config.credentials") + credentials.show_status() + end, + ["switch-provider"] = function() + local credentials = require("codetyper.config.credentials") + credentials.interactive_switch_provider() + end, + ["model"] = function(args) + local credentials = require("codetyper.config.credentials") + local codetyper = require("codetyper") + local config = codetyper.get_config() + local provider = config.llm.provider - -- Only available for Copilot provider - if provider ~= "copilot" then - utils.notify("CoderModel is only available when using Copilot provider. Current: " .. provider:upper(), vim.log.levels.WARN) - return - end + -- Only available for Copilot provider + if provider ~= "copilot" then + utils.notify( + "CoderModel is only available when using Copilot provider. Current: " .. provider:upper(), + vim.log.levels.WARN + ) + return + end - local model_arg = args.fargs[2] - if model_arg and model_arg ~= "" then - local cost = credentials.get_copilot_model_cost(model_arg) or "custom" - credentials.set_credentials("copilot", { model = model_arg, configured = true }) - utils.notify("Copilot model set to: " .. model_arg .. " — " .. cost, vim.log.levels.INFO) - else - credentials.interactive_copilot_config(true) - end - end, - } + local model_arg = args.fargs[2] + if model_arg and model_arg ~= "" then + local cost = credentials.get_copilot_model_cost(model_arg) or "custom" + credentials.set_credentials("copilot", { model = model_arg, configured = true }) + utils.notify("Copilot model set to: " .. model_arg .. " — " .. cost, vim.log.levels.INFO) + else + credentials.interactive_copilot_config(true) + end + end, + } - local cmd_fn = commands[subcommand] - if cmd_fn then - cmd_fn(args) - else - utils.notify("Unknown subcommand: " .. subcommand, vim.log.levels.ERROR) - end + local cmd_fn = commands[subcommand] + if cmd_fn then + cmd_fn(args) + else + utils.notify("Unknown subcommand: " .. subcommand, vim.log.levels.ERROR) + end end --- Setup all commands function M.setup() - vim.api.nvim_create_user_command("Coder", coder_cmd, { - nargs = "?", - complete = function() - return { - "open", "close", "toggle", "process", "status", "focus", - "tree", "tree-view", "reset", "gitignore", - "transform", "transform-cursor", - "index-project", "index-status", "memories", "forget", - "auto-toggle", "auto-set", - "llm-stats", "llm-feedback-good", "llm-feedback-bad", "llm-reset-stats", - "cost", "cost-clear", - "add-api-key", "remove-api-key", "credentials", "switch-provider", "model", - } - end, - desc = "Codetyper.nvim commands", - }) + vim.api.nvim_create_user_command("Coder", coder_cmd, { + nargs = "?", + complete = function() + return { + "open", + "close", + "toggle", + "process", + "status", + "focus", + "tree", + "tree-view", + "reset", + "gitignore", + "transform", + "transform-cursor", + "index-project", + "index-status", + "memories", + "forget", + "auto-toggle", + "auto-set", + "llm-stats", + "llm-feedback-good", + "llm-feedback-bad", + "llm-reset-stats", + "cost", + "cost-clear", + "add-api-key", + "remove-api-key", + "credentials", + "switch-provider", + "model", + } + end, + desc = "Codetyper.nvim commands", + }) - -- Convenience aliases - vim.api.nvim_create_user_command("CoderOpen", function() - cmd_open() - end, { desc = "Open Coder view" }) + -- Convenience aliases + vim.api.nvim_create_user_command("CoderOpen", function() + cmd_open() + end, { desc = "Open Coder view" }) - vim.api.nvim_create_user_command("CoderClose", function() - cmd_close() - end, { desc = "Close Coder view" }) + vim.api.nvim_create_user_command("CoderClose", function() + cmd_close() + end, { desc = "Close Coder view" }) - vim.api.nvim_create_user_command("CoderToggle", function() - cmd_toggle() - end, { desc = "Toggle Coder view" }) + vim.api.nvim_create_user_command("CoderToggle", function() + cmd_toggle() + end, { desc = "Toggle Coder view" }) - vim.api.nvim_create_user_command("CoderProcess", function() - cmd_process() - end, { desc = "Process prompt and generate code" }) + vim.api.nvim_create_user_command("CoderProcess", function() + cmd_process() + end, { desc = "Process prompt and generate code" }) - vim.api.nvim_create_user_command("CoderTree", function() - cmd_tree() - end, { desc = "Refresh tree.log" }) + vim.api.nvim_create_user_command("CoderTree", function() + cmd_tree() + end, { desc = "Refresh tree.log" }) - vim.api.nvim_create_user_command("CoderTreeView", function() - cmd_tree_view() - end, { desc = "View tree.log" }) + vim.api.nvim_create_user_command("CoderTreeView", function() + cmd_tree_view() + end, { desc = "View tree.log" }) - -- Transform commands (inline /@ @/ tag replacement) - vim.api.nvim_create_user_command("CoderTransform", function() - cmd_transform() - end, { desc = "Transform all /@ @/ tags in current file" }) + -- Transform commands (inline /@ @/ tag replacement) + vim.api.nvim_create_user_command("CoderTransform", function() + cmd_transform() + end, { desc = "Transform all /@ @/ tags in current file" }) - vim.api.nvim_create_user_command("CoderTransformCursor", function() - cmd_transform_at_cursor() - end, { desc = "Transform /@ @/ tag at cursor" }) + vim.api.nvim_create_user_command("CoderTransformCursor", function() + cmd_transform_at_cursor() + end, { desc = "Transform /@ @/ tag at cursor" }) - vim.api.nvim_create_user_command("CoderTransformVisual", function(opts) - local start_line = opts.line1 - local end_line = opts.line2 - cmd_transform_range(start_line, end_line) - end, { range = true, desc = "Transform /@ @/ tags in visual selection" }) + vim.api.nvim_create_user_command("CoderTransformVisual", function(opts) + local start_line = opts.line1 + local end_line = opts.line2 + cmd_transform_range(start_line, end_line) + end, { range = true, desc = "Transform /@ @/ tags in visual selection" }) - -- Index command - open coder companion for current file - vim.api.nvim_create_user_command("CoderIndex", function() - local autocmds = require("codetyper.adapters.nvim.autocmds") - autocmds.open_coder_companion() - end, { desc = "Open coder companion for current file" }) + -- Index command - open coder companion for current file + vim.api.nvim_create_user_command("CoderIndex", function() + local autocmds = require("codetyper.adapters.nvim.autocmds") + autocmds.open_coder_companion() + end, { desc = "Open coder companion for current file" }) - -- Project indexer commands - vim.api.nvim_create_user_command("CoderIndexProject", function() - cmd_index_project() - end, { desc = "Index the entire project" }) + -- Project indexer commands + vim.api.nvim_create_user_command("CoderIndexProject", function() + cmd_index_project() + end, { desc = "Index the entire project" }) - vim.api.nvim_create_user_command("CoderIndexStatus", function() - cmd_index_status() - end, { desc = "Show project index status" }) + vim.api.nvim_create_user_command("CoderIndexStatus", function() + cmd_index_status() + end, { desc = "Show project index status" }) - vim.api.nvim_create_user_command("CoderMemories", function() - cmd_memories() - end, { desc = "Show learned memories" }) + vim.api.nvim_create_user_command("CoderMemories", function() + cmd_memories() + end, { desc = "Show learned memories" }) - vim.api.nvim_create_user_command("CoderForget", function(opts) - cmd_forget(opts.args ~= "" and opts.args or nil) - end, { - desc = "Clear memories (optionally matching pattern)", - nargs = "?", - }) + vim.api.nvim_create_user_command("CoderForget", function(opts) + cmd_forget(opts.args ~= "" and opts.args or nil) + end, { + desc = "Clear memories (optionally matching pattern)", + nargs = "?", + }) - -- Preferences commands - vim.api.nvim_create_user_command("CoderAutoToggle", function() - local preferences = require("codetyper.config.preferences") - preferences.toggle_auto_process() - end, { desc = "Toggle automatic/manual prompt processing" }) + -- Preferences commands + vim.api.nvim_create_user_command("CoderAutoToggle", function() + local preferences = require("codetyper.config.preferences") + preferences.toggle_auto_process() + end, { desc = "Toggle automatic/manual prompt processing" }) - vim.api.nvim_create_user_command("CoderAutoSet", function(opts) - local preferences = require("codetyper.config.preferences") - local arg = opts.args:lower() - if arg == "auto" or arg == "automatic" or arg == "on" then - preferences.set_auto_process(true) - vim.notify("Codetyper: Set to automatic mode", vim.log.levels.INFO) - elseif arg == "manual" or arg == "off" then - preferences.set_auto_process(false) - vim.notify("Codetyper: Set to manual mode", vim.log.levels.INFO) - else - -- Show current mode - local auto = preferences.is_auto_process_enabled() - if auto == nil then - vim.notify("Codetyper: Mode not set yet (will ask on first prompt)", vim.log.levels.INFO) - else - local mode = auto and "automatic" or "manual" - vim.notify("Codetyper: Currently in " .. mode .. " mode", vim.log.levels.INFO) - end - end - end, { - desc = "Set prompt processing mode (auto/manual)", - nargs = "?", - complete = function() - return { "auto", "manual" } - end, - }) + vim.api.nvim_create_user_command("CoderAutoSet", function(opts) + local preferences = require("codetyper.config.preferences") + local arg = opts.args:lower() + if arg == "auto" or arg == "automatic" or arg == "on" then + preferences.set_auto_process(true) + vim.notify("Codetyper: Set to automatic mode", vim.log.levels.INFO) + elseif arg == "manual" or arg == "off" then + preferences.set_auto_process(false) + vim.notify("Codetyper: Set to manual mode", vim.log.levels.INFO) + else + -- Show current mode + local auto = preferences.is_auto_process_enabled() + if auto == nil then + vim.notify("Codetyper: Mode not set yet (will ask on first prompt)", vim.log.levels.INFO) + else + local mode = auto and "automatic" or "manual" + vim.notify("Codetyper: Currently in " .. mode .. " mode", vim.log.levels.INFO) + end + end + end, { + desc = "Set prompt processing mode (auto/manual)", + nargs = "?", + complete = function() + return { "auto", "manual" } + end, + }) - -- Brain feedback command - teach the brain from your experience - vim.api.nvim_create_user_command("CoderFeedback", function(opts) - local brain = require("codetyper.core.memory") - if not brain.is_initialized() then - vim.notify("Brain not initialized", vim.log.levels.WARN) - return - end + -- Brain feedback command - teach the brain from your experience + vim.api.nvim_create_user_command("CoderFeedback", function(opts) + local brain = require("codetyper.core.memory") + if not brain.is_initialized() then + vim.notify("Brain not initialized", vim.log.levels.WARN) + return + end - local feedback_type = opts.args:lower() - local current_file = vim.fn.expand("%:p") + local feedback_type = opts.args:lower() + local current_file = vim.fn.expand("%:p") - if feedback_type == "good" or feedback_type == "accept" or feedback_type == "+" then - -- Learn positive feedback - brain.learn({ - type = "user_feedback", - file = current_file, - timestamp = os.time(), - data = { - feedback = "accepted", - description = "User marked code as good/accepted", - }, - }) - vim.notify("Brain: Learned positive feedback ✓", vim.log.levels.INFO) + if feedback_type == "good" or feedback_type == "accept" or feedback_type == "+" then + -- Learn positive feedback + brain.learn({ + type = "user_feedback", + file = current_file, + timestamp = os.time(), + data = { + feedback = "accepted", + description = "User marked code as good/accepted", + }, + }) + vim.notify("Brain: Learned positive feedback ✓", vim.log.levels.INFO) + elseif feedback_type == "bad" or feedback_type == "reject" or feedback_type == "-" then + -- Learn negative feedback + brain.learn({ + type = "user_feedback", + file = current_file, + timestamp = os.time(), + data = { + feedback = "rejected", + description = "User marked code as bad/rejected", + }, + }) + vim.notify("Brain: Learned negative feedback ✗", vim.log.levels.INFO) + elseif feedback_type == "stats" or feedback_type == "status" then + -- Show brain stats + local stats = brain.stats() + local msg = string.format( + "Brain Stats:\n• Nodes: %d\n• Edges: %d\n• Pending: %d\n• Deltas: %d", + stats.node_count or 0, + stats.edge_count or 0, + stats.pending_changes or 0, + stats.delta_count or 0 + ) + vim.notify(msg, vim.log.levels.INFO) + else + vim.notify("Usage: CoderFeedback ", vim.log.levels.INFO) + end + end, { + desc = "Give feedback to the brain (good/bad/stats)", + nargs = "?", + complete = function() + return { "good", "bad", "stats" } + end, + }) - elseif feedback_type == "bad" or feedback_type == "reject" or feedback_type == "-" then - -- Learn negative feedback - brain.learn({ - type = "user_feedback", - file = current_file, - timestamp = os.time(), - data = { - feedback = "rejected", - description = "User marked code as bad/rejected", - }, - }) - vim.notify("Brain: Learned negative feedback ✗", vim.log.levels.INFO) + -- Brain stats command + vim.api.nvim_create_user_command("CoderBrain", function(opts) + local brain = require("codetyper.core.memory") + if not brain.is_initialized() then + vim.notify("Brain not initialized", vim.log.levels.WARN) + return + end - elseif feedback_type == "stats" or feedback_type == "status" then - -- Show brain stats - local stats = brain.stats() - local msg = string.format( - "Brain Stats:\n• Nodes: %d\n• Edges: %d\n• Pending: %d\n• Deltas: %d", - stats.node_count or 0, - stats.edge_count or 0, - stats.pending_changes or 0, - stats.delta_count or 0 - ) - vim.notify(msg, vim.log.levels.INFO) + local action = opts.args:lower() - else - vim.notify("Usage: CoderFeedback ", vim.log.levels.INFO) - end - end, { - desc = "Give feedback to the brain (good/bad/stats)", - nargs = "?", - complete = function() - return { "good", "bad", "stats" } - end, - }) + if action == "stats" or action == "" then + local stats = brain.stats() + local lines = { + "╭─────────────────────────────────╮", + "│ CODETYPER BRAIN │", + "╰─────────────────────────────────╯", + "", + string.format(" Nodes: %d", stats.node_count or 0), + string.format(" Edges: %d", stats.edge_count or 0), + string.format(" Deltas: %d", stats.delta_count or 0), + string.format(" Pending: %d", stats.pending_changes or 0), + "", + " The more you use Codetyper,", + " the smarter it becomes!", + } + vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) + elseif action == "commit" then + local hash = brain.commit("Manual commit") + if hash then + vim.notify("Brain: Committed changes (hash: " .. hash:sub(1, 8) .. ")", vim.log.levels.INFO) + else + vim.notify("Brain: Nothing to commit", vim.log.levels.INFO) + end + elseif action == "flush" then + brain.flush() + vim.notify("Brain: Flushed to disk", vim.log.levels.INFO) + elseif action == "prune" then + local pruned = brain.prune() + vim.notify("Brain: Pruned " .. pruned .. " low-value nodes", vim.log.levels.INFO) + else + vim.notify("Usage: CoderBrain ", vim.log.levels.INFO) + end + end, { + desc = "Brain management commands", + nargs = "?", + complete = function() + return { "stats", "commit", "flush", "prune" } + end, + }) - -- Brain stats command - vim.api.nvim_create_user_command("CoderBrain", function(opts) - local brain = require("codetyper.core.memory") - if not brain.is_initialized() then - vim.notify("Brain not initialized", vim.log.levels.WARN) - return - end + -- Cost estimation command + vim.api.nvim_create_user_command("CoderCost", function() + local cost = require("codetyper.core.cost") + cost.toggle() + end, { desc = "Show LLM cost estimation window" }) - local action = opts.args:lower() + -- Credentials management commands + vim.api.nvim_create_user_command("CoderAddApiKey", function() + local credentials = require("codetyper.config.credentials") + credentials.interactive_add() + end, { desc = "Add or update LLM provider API key" }) - if action == "stats" or action == "" then - local stats = brain.stats() - local lines = { - "╭─────────────────────────────────╮", - "│ CODETYPER BRAIN │", - "╰─────────────────────────────────╯", - "", - string.format(" Nodes: %d", stats.node_count or 0), - string.format(" Edges: %d", stats.edge_count or 0), - string.format(" Deltas: %d", stats.delta_count or 0), - string.format(" Pending: %d", stats.pending_changes or 0), - "", - " The more you use Codetyper,", - " the smarter it becomes!", - } - vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO) + vim.api.nvim_create_user_command("CoderRemoveApiKey", function() + local credentials = require("codetyper.config.credentials") + credentials.interactive_remove() + end, { desc = "Remove LLM provider credentials" }) - elseif action == "commit" then - local hash = brain.commit("Manual commit") - if hash then - vim.notify("Brain: Committed changes (hash: " .. hash:sub(1, 8) .. ")", vim.log.levels.INFO) - else - vim.notify("Brain: Nothing to commit", vim.log.levels.INFO) - end + vim.api.nvim_create_user_command("CoderCredentials", function() + local credentials = require("codetyper.config.credentials") + credentials.show_status() + end, { desc = "Show credentials status" }) - elseif action == "flush" then - brain.flush() - vim.notify("Brain: Flushed to disk", vim.log.levels.INFO) + vim.api.nvim_create_user_command("CoderSwitchProvider", function() + local credentials = require("codetyper.config.credentials") + credentials.interactive_switch_provider() + end, { desc = "Switch active LLM provider" }) - elseif action == "prune" then - local pruned = brain.prune() - vim.notify("Brain: Pruned " .. pruned .. " low-value nodes", vim.log.levels.INFO) + -- Quick model switcher command (Copilot only) + vim.api.nvim_create_user_command("CoderModel", function(opts) + local credentials = require("codetyper.adapters.config.credentials") + local codetyper = require("codetyper") + local config = codetyper.get_config() + local provider = config.llm.provider - else - vim.notify("Usage: CoderBrain ", vim.log.levels.INFO) - end - end, { - desc = "Brain management commands", - nargs = "?", - complete = function() - return { "stats", "commit", "flush", "prune" } - end, - }) + -- Only available for Copilot provider + if provider ~= "copilot" then + utils.notify( + "CoderModel is only available when using Copilot provider. Current: " .. provider:upper(), + vim.log.levels.WARN + ) + return + end - -- Cost estimation command - vim.api.nvim_create_user_command("CoderCost", function() - local cost = require("codetyper.core.cost") - cost.toggle() - end, { desc = "Show LLM cost estimation window" }) + -- If an argument is provided, set the model directly + if opts.args and opts.args ~= "" then + local cost = credentials.get_copilot_model_cost(opts.args) or "custom" + credentials.set_credentials("copilot", { model = opts.args, configured = true }) + utils.notify("Copilot model set to: " .. opts.args .. " — " .. cost, vim.log.levels.INFO) + return + end - -- Credentials management commands - vim.api.nvim_create_user_command("CoderAddApiKey", function() - local credentials = require("codetyper.credentials") - credentials.interactive_add() - end, { desc = "Add or update LLM provider API key" }) + -- Show interactive selector with costs (silent mode - no OAuth message) + credentials.interactive_copilot_config(true) + end, { + nargs = "?", + desc = "Quick switch Copilot model (only available with Copilot provider)", + complete = function() + local codetyper = require("codetyper") + local credentials = require("codetyper.config.credentials") + local config = codetyper.get_config() + if config.llm.provider == "copilot" then + return credentials.get_copilot_model_names() + end + return {} + end, + }) - vim.api.nvim_create_user_command("CoderRemoveApiKey", function() - local credentials = require("codetyper.credentials") - credentials.interactive_remove() - end, { desc = "Remove LLM provider credentials" }) - - vim.api.nvim_create_user_command("CoderCredentials", function() - local credentials = require("codetyper.credentials") - credentials.show_status() - end, { desc = "Show credentials status" }) - - vim.api.nvim_create_user_command("CoderSwitchProvider", function() - local credentials = require("codetyper.credentials") - credentials.interactive_switch_provider() - end, { desc = "Switch active LLM provider" }) - - -- Quick model switcher command (Copilot only) - vim.api.nvim_create_user_command("CoderModel", function(opts) - local credentials = require("codetyper.credentials") - local codetyper = require("codetyper") - local config = codetyper.get_config() - local provider = config.llm.provider - - -- Only available for Copilot provider - if provider ~= "copilot" then - utils.notify("CoderModel is only available when using Copilot provider. Current: " .. provider:upper(), vim.log.levels.WARN) - return - end - - -- If an argument is provided, set the model directly - if opts.args and opts.args ~= "" then - local cost = credentials.get_copilot_model_cost(opts.args) or "custom" - credentials.set_credentials("copilot", { model = opts.args, configured = true }) - utils.notify("Copilot model set to: " .. opts.args .. " — " .. cost, vim.log.levels.INFO) - return - end - - -- Show interactive selector with costs (silent mode - no OAuth message) - credentials.interactive_copilot_config(true) - end, { - nargs = "?", - desc = "Quick switch Copilot model (only available with Copilot provider)", - complete = function() - local codetyper = require("codetyper") - local credentials = require("codetyper.credentials") - local config = codetyper.get_config() - if config.llm.provider == "copilot" then - return credentials.get_copilot_model_names() - end - return {} - end, - }) - - -- Setup default keymaps - M.setup_keymaps() + -- Setup default keymaps + M.setup_keymaps() end --- Setup default keymaps for transform commands function M.setup_keymaps() - -- Visual mode: transform selected /@ @/ tags - vim.keymap.set("v", "ctt", ":CoderTransformVisual", { - silent = true, - desc = "Coder: Transform selected tags" - }) + -- Visual mode: transform selected /@ @/ tags + vim.keymap.set("v", "ctt", ":CoderTransformVisual", { + silent = true, + desc = "Coder: Transform selected tags", + }) - -- Normal mode: transform tag at cursor - vim.keymap.set("n", "ctt", "CoderTransformCursor", { - silent = true, - desc = "Coder: Transform tag at cursor" - }) + -- Normal mode: transform tag at cursor + vim.keymap.set("n", "ctt", "CoderTransformCursor", { + silent = true, + desc = "Coder: Transform tag at cursor", + }) - -- Normal mode: transform all tags in file - vim.keymap.set("n", "ctT", "CoderTransform", { - silent = true, - desc = "Coder: Transform all tags in file" - }) + -- Normal mode: transform all tags in file + vim.keymap.set("n", "ctT", "CoderTransform", { + silent = true, + desc = "Coder: Transform all tags in file", + }) - -- Index keymap - open coder companion - vim.keymap.set("n", "ci", "CoderIndex", { - silent = true, - desc = "Coder: Open coder companion for file" - }) + -- Index keymap - open coder companion + vim.keymap.set("n", "ci", "CoderIndex", { + silent = true, + desc = "Coder: Open coder companion for file", + }) end return M diff --git a/lua/codetyper/adapters/nvim/ui/chat.lua b/lua/codetyper/adapters/nvim/ui/chat.lua new file mode 100644 index 0000000..0b697c3 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/chat.lua @@ -0,0 +1,907 @@ +---@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 new file mode 100644 index 0000000..0aa98e3 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/context_modal.lua @@ -0,0 +1,381 @@ +---@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 new file mode 100644 index 0000000..d124360 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/diff_review.lua @@ -0,0 +1,386 @@ +---@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 new file mode 100644 index 0000000..ce904ee --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/logs.lua @@ -0,0 +1,380 @@ +---@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 new file mode 100644 index 0000000..25cdfb4 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/logs_panel.lua @@ -0,0 +1,382 @@ +---@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 new file mode 100644 index 0000000..c65ad05 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/switcher.lua @@ -0,0 +1,44 @@ +---@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/config/credentials.lua b/lua/codetyper/config/credentials.lua index 837b9e4..8589d32 100644 --- a/lua/codetyper/config/credentials.lua +++ b/lua/codetyper/config/credentials.lua @@ -1,4 +1,4 @@ ----@mod codetyper.credentials Secure credential storage for Codetyper.nvim +---@mod codetyper.config.credentials Secure credential storage for Codetyper.nvim ---@brief [[ --- Manages API keys and model preferences stored outside of config files. --- Credentials are stored in ~/.local/share/nvim/codetyper/configuration.json @@ -512,12 +512,10 @@ function M.show_status() end local model_info = p.model and (" - " .. p.model) or "" - table.insert(lines, string.format(" %s %s%s%s%s", - status_icon, - p.name:upper(), - active_marker, - source_info, - model_info)) + table.insert( + lines, + string.format(" %s %s%s%s%s", status_icon, p.name:upper(), active_marker, source_info, model_info) + ) end table.insert(lines, "") diff --git a/lua/codetyper/core/diff/conflict.lua b/lua/codetyper/core/diff/conflict.lua new file mode 100644 index 0000000..7465ad8 --- /dev/null +++ b/lua/codetyper/core/diff/conflict.lua @@ -0,0 +1,1052 @@ +---@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 new file mode 100644 index 0000000..53c0ea1 --- /dev/null +++ b/lua/codetyper/core/diff/diff.lua @@ -0,0 +1,320 @@ +---@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 new file mode 100644 index 0000000..232a6dd --- /dev/null +++ b/lua/codetyper/core/diff/patch.lua @@ -0,0 +1,1098 @@ +---@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 new file mode 100644 index 0000000..0fc6eb9 --- /dev/null +++ b/lua/codetyper/core/diff/search_replace.lua @@ -0,0 +1,572 @@ +---@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 new file mode 100644 index 0000000..169e5c9 --- /dev/null +++ b/lua/codetyper/core/intent/init.lua @@ -0,0 +1,117 @@ +---@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 new file mode 100644 index 0000000..5dc5870 --- /dev/null +++ b/lua/codetyper/core/llm/confidence.lua @@ -0,0 +1,275 @@ +---@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 b1d2b19..dc4199b 100644 --- a/lua/codetyper/core/llm/copilot.lua +++ b/lua/codetyper/core/llm/copilot.lua @@ -100,7 +100,7 @@ end ---@return string Model name local function get_model() -- Priority: stored credentials > config - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_model = credentials.get_model("copilot") if stored_model then return stored_model @@ -224,8 +224,7 @@ end ---@param body table Request body ---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) local function make_request(token, body, callback) - local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com") - .. "/chat/completions" + local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com") .. "/chat/completions" local json_body = vim.json.encode(body) local headers = build_headers(token) @@ -280,7 +279,10 @@ local function make_request(token, body, callback) if response.error then local error_msg = response.error.message or "Copilot API error" - if response.error.code == "rate_limit_exceeded" or (error_msg:match("limit") and error_msg:match("plan")) then + if + response.error.code == "rate_limit_exceeded" + or (error_msg:match("limit") and error_msg:match("plan")) + then error_msg = "Copilot rate limit: " .. error_msg M.suggest_ollama_fallback(error_msg) end diff --git a/lua/codetyper/core/llm/gemini.lua b/lua/codetyper/core/llm/gemini.lua index 6585328..17be80c 100644 --- a/lua/codetyper/core/llm/gemini.lua +++ b/lua/codetyper/core/llm/gemini.lua @@ -12,7 +12,7 @@ local API_URL = "https://generativelanguage.googleapis.com/v1beta/models" ---@return string|nil API key local function get_api_key() -- Priority: stored credentials > config > environment - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_key = credentials.get_api_key("gemini") if stored_key then return stored_key @@ -27,7 +27,7 @@ end ---@return string Model name local function get_model() -- Priority: stored credentials > config - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_model = credentials.get_model("gemini") if stored_model then return stored_model diff --git a/lua/codetyper/core/llm/ollama.lua b/lua/codetyper/core/llm/ollama.lua index 9b520b9..4b27672 100644 --- a/lua/codetyper/core/llm/ollama.lua +++ b/lua/codetyper/core/llm/ollama.lua @@ -9,7 +9,7 @@ local llm = require("codetyper.core.llm") ---@return string Host URL local function get_host() -- Priority: stored credentials > config - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_host = credentials.get_ollama_host() if stored_host then return stored_host @@ -24,7 +24,7 @@ end ---@return string Model name local function get_model() -- Priority: stored credentials > config - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_model = credentials.get_model("ollama") if stored_model then return stored_model diff --git a/lua/codetyper/core/llm/openai.lua b/lua/codetyper/core/llm/openai.lua index 5bb23dc..0f2a06c 100644 --- a/lua/codetyper/core/llm/openai.lua +++ b/lua/codetyper/core/llm/openai.lua @@ -12,7 +12,7 @@ local API_URL = "https://api.openai.com/v1/chat/completions" ---@return string|nil API key local function get_api_key() -- Priority: stored credentials > config > environment - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_key = credentials.get_api_key("openai") if stored_key then return stored_key @@ -27,7 +27,7 @@ end ---@return string Model name local function get_model() -- Priority: stored credentials > config - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_model = credentials.get_model("openai") if stored_model then return stored_model @@ -42,7 +42,7 @@ end ---@return string API endpoint local function get_endpoint() -- Priority: stored credentials > config > default - local credentials = require("codetyper.credentials") + local credentials = require("codetyper.config.credentials") local stored_endpoint = credentials.get_endpoint("openai") if stored_endpoint then return stored_endpoint diff --git a/lua/codetyper/core/scheduler/executor.lua b/lua/codetyper/core/scheduler/executor.lua new file mode 100644 index 0000000..ddb5d32 --- /dev/null +++ b/lua/codetyper/core/scheduler/executor.lua @@ -0,0 +1,616 @@ +---@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 new file mode 100644 index 0000000..2f73222 --- /dev/null +++ b/lua/codetyper/core/scheduler/loop.lua @@ -0,0 +1,381 @@ +---@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 new file mode 100644 index 0000000..fcf3052 --- /dev/null +++ b/lua/codetyper/core/scheduler/resume.lua @@ -0,0 +1,155 @@ +---@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 new file mode 100644 index 0000000..c002369 --- /dev/null +++ b/lua/codetyper/core/scheduler/scheduler.lua @@ -0,0 +1,756 @@ +---@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 new file mode 100644 index 0000000..103c4c1 --- /dev/null +++ b/lua/codetyper/core/scheduler/worker.lua @@ -0,0 +1,1034 @@ +---@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 new file mode 100644 index 0000000..4386830 --- /dev/null +++ b/lua/codetyper/core/scope/init.lua @@ -0,0 +1,431 @@ +---@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/params/agents/bash.lua b/lua/codetyper/params/agents/bash.lua new file mode 100644 index 0000000..5a81e86 --- /dev/null +++ b/lua/codetyper/params/agents/bash.lua @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..2cb192b --- /dev/null +++ b/lua/codetyper/params/agents/confidence.lua @@ -0,0 +1,40 @@ +---@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 new file mode 100644 index 0000000..5c77656 --- /dev/null +++ b/lua/codetyper/params/agents/conflict.lua @@ -0,0 +1,33 @@ +---@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 new file mode 100644 index 0000000..cfa4acb --- /dev/null +++ b/lua/codetyper/params/agents/context.lua @@ -0,0 +1,48 @@ +---@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 new file mode 100644 index 0000000..3f353cd --- /dev/null +++ b/lua/codetyper/params/agents/edit.lua @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..b977a46 --- /dev/null +++ b/lua/codetyper/params/agents/grep.lua @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..4a5a411 --- /dev/null +++ b/lua/codetyper/params/agents/intent.lua @@ -0,0 +1,161 @@ +---@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 new file mode 100644 index 0000000..7aee62c --- /dev/null +++ b/lua/codetyper/params/agents/languages.lua @@ -0,0 +1,87 @@ +---@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 new file mode 100644 index 0000000..88644b1 --- /dev/null +++ b/lua/codetyper/params/agents/linter.lua @@ -0,0 +1,15 @@ +---@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 new file mode 100644 index 0000000..6346f73 --- /dev/null +++ b/lua/codetyper/params/agents/logs.lua @@ -0,0 +1,36 @@ +---@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 new file mode 100644 index 0000000..d014634 --- /dev/null +++ b/lua/codetyper/params/agents/parser.lua @@ -0,0 +1,15 @@ +---@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 new file mode 100644 index 0000000..c328e0e --- /dev/null +++ b/lua/codetyper/params/agents/patch.lua @@ -0,0 +1,12 @@ +---@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 new file mode 100644 index 0000000..bf49d34 --- /dev/null +++ b/lua/codetyper/params/agents/permissions.lua @@ -0,0 +1,47 @@ +---@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 new file mode 100644 index 0000000..4936b17 --- /dev/null +++ b/lua/codetyper/params/agents/scheduler.lua @@ -0,0 +1,14 @@ +---@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 new file mode 100644 index 0000000..aa15eae --- /dev/null +++ b/lua/codetyper/params/agents/scope.lua @@ -0,0 +1,72 @@ +---@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 new file mode 100644 index 0000000..54ede10 --- /dev/null +++ b/lua/codetyper/params/agents/search_replace.lua @@ -0,0 +1,11 @@ +---@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 new file mode 100644 index 0000000..94379dd --- /dev/null +++ b/lua/codetyper/params/agents/tools.lua @@ -0,0 +1,147 @@ +---@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 new file mode 100644 index 0000000..64d2d4e --- /dev/null +++ b/lua/codetyper/params/agents/view.lua @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..b1220a9 --- /dev/null +++ b/lua/codetyper/params/agents/worker.lua @@ -0,0 +1,30 @@ +---@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 new file mode 100644 index 0000000..60a71b4 --- /dev/null +++ b/lua/codetyper/params/agents/write.lua @@ -0,0 +1,30 @@ +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/parser.lua b/lua/codetyper/parser.lua index f8aca6a..4be6a87 100644 --- a/lua/codetyper/parser.lua +++ b/lua/codetyper/parser.lua @@ -7,20 +7,20 @@ local utils = require("codetyper.support.utils") --- Get config with safe fallback ---@return table config local function get_config_safe() - local ok, codetyper = pcall(require, "codetyper") - if ok and codetyper.get_config then - local config = codetyper.get_config() - if config and config.patterns then - return config - end - end - -- Fallback defaults - return { - patterns = { - open_tag = "/@", - close_tag = "@/", - } - } + local ok, codetyper = pcall(require, "codetyper") + if ok and codetyper.get_config then + local config = codetyper.get_config() + if config and config.patterns then + return config + end + end + -- Fallback defaults + return { + patterns = { + open_tag = "/@", + close_tag = "@/", + }, + } end --- Find all prompts in buffer content @@ -29,145 +29,145 @@ end ---@param close_tag string Closing tag ---@return CoderPrompt[] List of found prompts function M.find_prompts(content, open_tag, close_tag) - local prompts = {} - local escaped_open = utils.escape_pattern(open_tag) - local escaped_close = utils.escape_pattern(close_tag) + local prompts = {} + local escaped_open = utils.escape_pattern(open_tag) + local escaped_close = utils.escape_pattern(close_tag) - local lines = vim.split(content, "\n", { plain = true }) - local in_prompt = false - local current_prompt = nil - local prompt_content = {} + local lines = vim.split(content, "\n", { plain = true }) + local in_prompt = false + local current_prompt = nil + local prompt_content = {} - for line_num, line in ipairs(lines) do - if not in_prompt then - -- Look for opening tag - local start_col = line:find(escaped_open) - if start_col then - in_prompt = true - current_prompt = { - start_line = line_num, - start_col = start_col, - content = "", - } - -- Get content after opening tag on same line - local after_tag = line:sub(start_col + #open_tag) - local end_col = after_tag:find(escaped_close) - if end_col then - -- Single line prompt - current_prompt.content = after_tag:sub(1, end_col - 1) - current_prompt.end_line = line_num - current_prompt.end_col = start_col + #open_tag + end_col + #close_tag - 2 - table.insert(prompts, current_prompt) - in_prompt = false - current_prompt = nil - else - table.insert(prompt_content, after_tag) - end - end - else - -- Look for closing tag - local end_col = line:find(escaped_close) - if end_col then - -- Found closing tag - local before_tag = line:sub(1, end_col - 1) - table.insert(prompt_content, before_tag) - current_prompt.content = table.concat(prompt_content, "\n") - current_prompt.end_line = line_num - current_prompt.end_col = end_col + #close_tag - 1 - table.insert(prompts, current_prompt) - in_prompt = false - current_prompt = nil - prompt_content = {} - else - table.insert(prompt_content, line) - end - end - end + for line_num, line in ipairs(lines) do + if not in_prompt then + -- Look for opening tag + local start_col = line:find(escaped_open) + if start_col then + in_prompt = true + current_prompt = { + start_line = line_num, + start_col = start_col, + content = "", + } + -- Get content after opening tag on same line + local after_tag = line:sub(start_col + #open_tag) + local end_col = after_tag:find(escaped_close) + if end_col then + -- Single line prompt + current_prompt.content = after_tag:sub(1, end_col - 1) + current_prompt.end_line = line_num + current_prompt.end_col = start_col + #open_tag + end_col + #close_tag - 2 + table.insert(prompts, current_prompt) + in_prompt = false + current_prompt = nil + else + table.insert(prompt_content, after_tag) + end + end + else + -- Look for closing tag + local end_col = line:find(escaped_close) + if end_col then + -- Found closing tag + local before_tag = line:sub(1, end_col - 1) + table.insert(prompt_content, before_tag) + current_prompt.content = table.concat(prompt_content, "\n") + current_prompt.end_line = line_num + current_prompt.end_col = end_col + #close_tag - 1 + table.insert(prompts, current_prompt) + in_prompt = false + current_prompt = nil + prompt_content = {} + else + table.insert(prompt_content, line) + end + end + end - return prompts + return prompts end --- Find prompts in a buffer ---@param bufnr number Buffer number ---@return CoderPrompt[] List of found prompts function M.find_prompts_in_buffer(bufnr) - local config = get_config_safe() + local config = get_config_safe() - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local content = table.concat(lines, "\n") + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") - return M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag) + return M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag) end --- Get prompt at cursor position ---@param bufnr? number Buffer number (default: current) ---@return CoderPrompt|nil Prompt at cursor or nil function M.get_prompt_at_cursor(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - local cursor = vim.api.nvim_win_get_cursor(0) - local line = cursor[1] - local col = cursor[2] + 1 -- Convert to 1-indexed + bufnr = bufnr or vim.api.nvim_get_current_buf() + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] + local col = cursor[2] + 1 -- Convert to 1-indexed - local prompts = M.find_prompts_in_buffer(bufnr) + local prompts = M.find_prompts_in_buffer(bufnr) - for _, prompt in ipairs(prompts) do - if line >= prompt.start_line and line <= prompt.end_line then - if line == prompt.start_line and col < prompt.start_col then - goto continue - end - if line == prompt.end_line and col > prompt.end_col then - goto continue - end - return prompt - end - ::continue:: - end + for _, prompt in ipairs(prompts) do + if line >= prompt.start_line and line <= prompt.end_line then + if line == prompt.start_line and col < prompt.start_col then + goto continue + end + if line == prompt.end_line and col > prompt.end_col then + goto continue + end + return prompt + end + ::continue:: + end - return nil + return nil end --- Get the last closed prompt in buffer ---@param bufnr? number Buffer number (default: current) ---@return CoderPrompt|nil Last prompt or nil function M.get_last_prompt(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - local prompts = M.find_prompts_in_buffer(bufnr) + bufnr = bufnr or vim.api.nvim_get_current_buf() + local prompts = M.find_prompts_in_buffer(bufnr) - if #prompts > 0 then - return prompts[#prompts] - end + if #prompts > 0 then + return prompts[#prompts] + end - return nil + return nil end --- Extract the prompt type from content ---@param content string Prompt content ---@return "refactor" | "add" | "document" | "explain" | "generic" Prompt type function M.detect_prompt_type(content) - local lower = content:lower() + local lower = content:lower() - if lower:match("refactor") then - return "refactor" - elseif lower:match("add") or lower:match("create") or lower:match("implement") then - return "add" - elseif lower:match("document") or lower:match("comment") or lower:match("jsdoc") then - return "document" - elseif lower:match("explain") or lower:match("what") or lower:match("how") then - return "explain" - end + if lower:match("refactor") then + return "refactor" + elseif lower:match("add") or lower:match("create") or lower:match("implement") then + return "add" + elseif lower:match("document") or lower:match("comment") or lower:match("jsdoc") then + return "document" + elseif lower:match("explain") or lower:match("what") or lower:match("how") then + return "explain" + end - return "generic" + return "generic" end --- Clean prompt content (trim whitespace, normalize newlines) ---@param content string Raw prompt content ---@return string Cleaned content function M.clean_prompt(content) - -- Trim leading/trailing whitespace - content = content:match("^%s*(.-)%s*$") - -- Normalize multiple newlines - content = content:gsub("\n\n\n+", "\n\n") - return content + -- Trim leading/trailing whitespace + content = content:match("^%s*(.-)%s*$") + -- Normalize multiple newlines + content = content:gsub("\n\n\n+", "\n\n") + return content end --- Check if line contains a closing tag @@ -175,26 +175,26 @@ end ---@param close_tag string Closing tag ---@return boolean function M.has_closing_tag(line, close_tag) - return line:find(utils.escape_pattern(close_tag)) ~= nil + return line:find(utils.escape_pattern(close_tag)) ~= nil end --- Check if buffer has any unclosed prompts ---@param bufnr? number Buffer number (default: current) ---@return boolean function M.has_unclosed_prompts(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - local config = get_config_safe() + bufnr = bufnr or vim.api.nvim_get_current_buf() + local config = get_config_safe() - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local content = table.concat(lines, "\n") + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") - local escaped_open = utils.escape_pattern(config.patterns.open_tag) - local escaped_close = utils.escape_pattern(config.patterns.close_tag) + local escaped_open = utils.escape_pattern(config.patterns.open_tag) + local escaped_close = utils.escape_pattern(config.patterns.close_tag) - local _, open_count = content:gsub(escaped_open, "") - local _, close_count = content:gsub(escaped_close, "") + local _, open_count = content:gsub(escaped_open, "") + local _, close_count = content:gsub(escaped_close, "") - return open_count > close_count + return open_count > close_count end --- Extract file references from prompt content @@ -202,25 +202,25 @@ end ---@param content string Prompt content ---@return string[] List of file references function M.extract_file_references(content) - local files = {} - -- Pattern: @ followed by word char, dot, underscore, or dash as FIRST char - -- Then optionally more path characters including / - -- This ensures @/ is NOT matched (/ cannot be first char) - for file in content:gmatch("@([%w%._%-][%w%._%-/]*)") do - if file ~= "" then - table.insert(files, file) - end - end - return files + local files = {} + -- Pattern: @ followed by word char, dot, underscore, or dash as FIRST char + -- Then optionally more path characters including / + -- This ensures @/ is NOT matched (/ cannot be first char) + for file in content:gmatch("@([%w%._%-][%w%._%-/]*)") do + if file ~= "" then + table.insert(files, file) + end + end + return files end --- Remove file references from prompt content (for clean prompt text) ---@param content string Prompt content ---@return string Cleaned content without file references function M.strip_file_references(content) - -- Remove @filename patterns but preserve @/ closing tag - -- Pattern requires first char after @ to be word char, dot, underscore, or dash (NOT /) - return content:gsub("@([%w%._%-][%w%._%-/]*)", "") + -- Remove @filename patterns but preserve @/ closing tag + -- Pattern requires first char after @ to be word char, dot, underscore, or dash (NOT /) + return content:gsub("@([%w%._%-][%w%._%-/]*)", "") end --- Check if cursor is inside an unclosed prompt tag @@ -228,61 +228,61 @@ end ---@return boolean is_inside Whether cursor is inside an open tag ---@return number|nil start_line Line where the open tag starts function M.is_cursor_in_open_tag(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() - local config = get_config_safe() + bufnr = bufnr or vim.api.nvim_get_current_buf() + local config = get_config_safe() - local cursor = vim.api.nvim_win_get_cursor(0) - local cursor_line = cursor[1] + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_line, false) - local escaped_open = utils.escape_pattern(config.patterns.open_tag) - local escaped_close = utils.escape_pattern(config.patterns.close_tag) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_line, false) + local escaped_open = utils.escape_pattern(config.patterns.open_tag) + local escaped_close = utils.escape_pattern(config.patterns.close_tag) - local open_count = 0 - local close_count = 0 - local last_open_line = nil + local open_count = 0 + local close_count = 0 + local last_open_line = nil - for line_num, line in ipairs(lines) do - -- Count opens on this line - for _ in line:gmatch(escaped_open) do - open_count = open_count + 1 - last_open_line = line_num - end - -- Count closes on this line - for _ in line:gmatch(escaped_close) do - close_count = close_count + 1 - end - end + for line_num, line in ipairs(lines) do + -- Count opens on this line + for _ in line:gmatch(escaped_open) do + open_count = open_count + 1 + last_open_line = line_num + end + -- Count closes on this line + for _ in line:gmatch(escaped_close) do + close_count = close_count + 1 + end + end - local is_inside = open_count > close_count - return is_inside, is_inside and last_open_line or nil + local is_inside = open_count > close_count + return is_inside, is_inside and last_open_line or nil end --- Get the word being typed after @ symbol ---@param bufnr? number Buffer number ---@return string|nil prefix The text after @ being typed, or nil if not typing a file ref function M.get_file_ref_prefix(bufnr) - bufnr = bufnr or vim.api.nvim_get_current_buf() + bufnr = bufnr or vim.api.nvim_get_current_buf() - local cursor = vim.api.nvim_win_get_cursor(0) - local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1] - if not line then - return nil - end + local cursor = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1] + if not line then + return nil + end - local col = cursor[2] - local before_cursor = line:sub(1, col) + local col = cursor[2] + local before_cursor = line:sub(1, col) - -- Check if we're typing after @ but not @/ - -- Match @ followed by optional path characters at end of string - local prefix = before_cursor:match("@([%w%._%-/]*)$") + -- Check if we're typing after @ but not @/ + -- Match @ followed by optional path characters at end of string + local prefix = before_cursor:match("@([%w%._%-/]*)$") - -- Make sure it's not the closing tag pattern - if prefix and before_cursor:sub(-2) == "@/" then - return nil - end + -- Make sure it's not the closing tag pattern + if prefix and before_cursor:sub(-2) == "@/" then + return nil + end - return prefix + return prefix end return M diff --git a/lua/codetyper/prompts/agents/bash.lua b/lua/codetyper/prompts/agents/bash.lua new file mode 100644 index 0000000..db10d83 --- /dev/null +++ b/lua/codetyper/prompts/agents/bash.lua @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..5c9bb5e --- /dev/null +++ b/lua/codetyper/prompts/agents/diff.lua @@ -0,0 +1,66 @@ +---@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 new file mode 100644 index 0000000..2603a83 --- /dev/null +++ b/lua/codetyper/prompts/agents/edit.lua @@ -0,0 +1,14 @@ +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 new file mode 100644 index 0000000..9713c41 --- /dev/null +++ b/lua/codetyper/prompts/agents/grep.lua @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..2b98333 --- /dev/null +++ b/lua/codetyper/prompts/agents/init.lua @@ -0,0 +1,141 @@ +---@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 new file mode 100644 index 0000000..0c2dc15 --- /dev/null +++ b/lua/codetyper/prompts/agents/intent.lua @@ -0,0 +1,53 @@ +---@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 new file mode 100644 index 0000000..ef5ec09 --- /dev/null +++ b/lua/codetyper/prompts/agents/linter.lua @@ -0,0 +1,13 @@ +---@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 new file mode 100644 index 0000000..39bb09e --- /dev/null +++ b/lua/codetyper/prompts/agents/loop.lua @@ -0,0 +1,55 @@ +---@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 new file mode 100644 index 0000000..9d205c5 --- /dev/null +++ b/lua/codetyper/prompts/agents/modal.lua @@ -0,0 +1,14 @@ +---@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 new file mode 100644 index 0000000..4c8255f --- /dev/null +++ b/lua/codetyper/prompts/agents/personas.lua @@ -0,0 +1,58 @@ +---@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 new file mode 100644 index 0000000..92558bb --- /dev/null +++ b/lua/codetyper/prompts/agents/scheduler.lua @@ -0,0 +1,12 @@ +---@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 new file mode 100644 index 0000000..9acf785 --- /dev/null +++ b/lua/codetyper/prompts/agents/templates.lua @@ -0,0 +1,51 @@ +---@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 new file mode 100644 index 0000000..4a7d5eb --- /dev/null +++ b/lua/codetyper/prompts/agents/tools.lua @@ -0,0 +1,18 @@ +---@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 new file mode 100644 index 0000000..7f38e4c --- /dev/null +++ b/lua/codetyper/prompts/agents/view.lua @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..d15b87b --- /dev/null +++ b/lua/codetyper/prompts/agents/write.lua @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..37fe450 --- /dev/null +++ b/lua/codetyper/prompts/ask.lua @@ -0,0 +1,177 @@ +---@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 efc707f..eeb22bc 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)