diff --git a/README.md b/README.md index ac0b3a1..7ab2c64 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,16 @@ - curl (for API calls) - One of: Claude API key, OpenAI API key, Gemini API key, GitHub Copilot, or Ollama running locally +### Required Plugins + +- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) - Async utilities +- [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) - Scope detection for functions/methods + +### Optional Plugins + +- [nvim-treesitter-textobjects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects) - Better text object support +- [nui.nvim](https://github.com/MunifTanjim/nui.nvim) - UI components + --- ## 📦 Installation @@ -55,6 +65,12 @@ ```lua { "cargdev/codetyper.nvim", + dependencies = { + "nvim-lua/plenary.nvim", -- Required: async utilities + "nvim-treesitter/nvim-treesitter", -- Required: scope detection + "nvim-treesitter/nvim-treesitter-textobjects", -- Optional: text objects + "MunifTanjim/nui.nvim", -- Optional: UI components + }, cmd = { "Coder", "CoderOpen", "CoderToggle", "CoderAgent" }, keys = { { "co", "Coder open", desc = "Coder: Open" }, @@ -167,6 +183,7 @@ require("codetyper").setup({ escalation_threshold = 0.7, -- Below this confidence, escalate to remote max_concurrent = 2, -- Max parallel workers completion_delay_ms = 100, -- Delay injection after completion popup + apply_delay_ms = 5000, -- Wait before applying code (ms), allows review }, }) ``` @@ -317,11 +334,54 @@ The plugin auto-detects prompt type: | Keywords | Type | Behavior | |----------|------|----------| -| `refactor`, `rewrite` | Refactor | Replaces code | -| `add`, `create`, `implement` | Add | Inserts new code | -| `document`, `comment` | Document | Adds documentation | +| `complete`, `finish`, `implement`, `todo` | Complete | Completes function body (replaces scope) | +| `refactor`, `rewrite`, `simplify` | Refactor | Replaces code | +| `fix`, `debug`, `bug`, `error` | Fix | Fixes bugs (replaces scope) | +| `add`, `create`, `generate` | Add | Inserts new code | +| `document`, `comment`, `jsdoc` | Document | Adds documentation | +| `optimize`, `performance`, `faster` | Optimize | Optimizes code (replaces scope) | | `explain`, `what`, `how` | Explain | Shows explanation only | +### Function Completion + +When you write a prompt **inside** a function body, the plugin uses Tree-sitter to detect the enclosing scope and automatically switches to "complete" mode: + +```typescript +function getUserById(id: number): User | null { + /@ return the user from the database by id, handle not found case @/ +} +``` + +The LLM will complete the function body while keeping the exact same signature. The entire function scope is replaced with the completed version. + +--- + +## 📊 Logs Panel + +The logs panel provides real-time visibility into LLM operations: + +### Features + +- **Generation Logs**: Shows all LLM requests, responses, and token usage +- **Queue Display**: Shows pending and processing prompts +- **Full Response View**: Complete LLM responses are logged for debugging +- **Auto-cleanup**: Logs panel and queue windows automatically close when exiting Neovim + +### Opening the Logs Panel + +```vim +:CoderLogs +``` + +The logs panel opens automatically when processing prompts with the scheduler enabled. + +### Keymaps + +| Key | Description | +|-----|-------------| +| `q` | Close logs panel | +| `` | Close logs panel | + --- ## 🤖 Agent Mode diff --git a/lua/codetyper/agent/context_modal.lua b/lua/codetyper/agent/context_modal.lua index 5493836..aadcd0d 100644 --- a/lua/codetyper/agent/context_modal.lua +++ b/lua/codetyper/agent/context_modal.lua @@ -160,4 +160,18 @@ 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/agent/logs.lua b/lua/codetyper/agent/logs.lua index 9393bd3..3741479 100644 --- a/lua/codetyper/agent/logs.lua +++ b/lua/codetyper/agent/logs.lua @@ -230,9 +230,22 @@ function M.format_entry(entry) response = "<", tool = "T", error = "!", + warning = "?", + success = "i", + queue = "Q", + patch = "P", })[entry.level] or "?" - return string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message) + 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 --- Estimate token count for a string (rough approximation) diff --git a/lua/codetyper/agent/patch.lua b/lua/codetyper/agent/patch.lua index 42a08a1..b418b97 100644 --- a/lua/codetyper/agent/patch.lua +++ b/lua/codetyper/agent/patch.lua @@ -397,11 +397,30 @@ local function remove_prompt_tags(bufnr) 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 @@ -454,14 +473,87 @@ function M.apply(patch) -- Apply based on strategy local ok, err = pcall(function() if patch.injection_strategy == "replace" and patch.injection_range then - -- For replace, we need to adjust the range since we removed tags - -- Just append to end since the original context might have shifted - vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines) + -- Replace the scope range with the new code + -- The injection_range points to the function/method we're completing + local start_line = patch.injection_range.start_line + local end_line = patch.injection_range.end_line + + -- Adjust for tag removal - find the new range by searching for the scope + -- After removing tags, line numbers may have shifted + -- Use the scope information to find the correct range + if patch.scope and patch.scope.type then + -- Try to find the scope using treesitter if available + local found_range = nil + pcall(function() + local ts_utils = require("nvim-treesitter.ts_utils") + 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 + start_line = math.max(1, start_line) + end_line = math.min(line_count, end_line) + + -- Replace the range (0-indexed for nvim_buf_set_lines) + vim.api.nvim_buf_set_lines(target_bufnr, start_line - 1, end_line, false, code_lines) + + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = string.format("Replacing lines %d-%d with %d lines of code", start_line, end_line, #code_lines), + }) + end) elseif patch.injection_strategy == "insert" and patch.injection_range then - -- Insert at end since original position might have shifted - vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines) + -- Insert at the specified location + local insert_line = patch.injection_range.start_line + insert_line = math.max(1, math.min(line_count + 1, insert_line)) + vim.api.nvim_buf_set_lines(target_bufnr, insert_line - 1, insert_line - 1, false, code_lines) else -- Default: append to end + -- Check if last line is empty, if not add a blank line for spacing + local last_line = vim.api.nvim_buf_get_lines(target_bufnr, line_count - 1, line_count, false)[1] or "" + if last_line:match("%S") then + -- Last line has content, add blank line for spacing + table.insert(code_lines, 1, "") + end vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines) end end) @@ -491,22 +583,27 @@ 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 _, patch in ipairs(patches) do - if patch.status == "pending" then - local success, _ = M.apply(patch) + 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 + return applied, stale, deferred end --- Cancel all pending patches for a buffer diff --git a/lua/codetyper/agent/scheduler.lua b/lua/codetyper/agent/scheduler.lua index b8decf9..73483b7 100644 --- a/lua/codetyper/agent/scheduler.lua +++ b/lua/codetyper/agent/scheduler.lua @@ -12,6 +12,9 @@ local worker = require("codetyper.agent.worker") local confidence_mod = require("codetyper.agent.confidence") local context_modal = require("codetyper.agent.context_modal") +-- Setup context modal cleanup on exit +context_modal.setup() + --- Scheduler state local state = { running = false, @@ -24,6 +27,7 @@ local state = { escalation_threshold = 0.7, max_concurrent = 2, completion_delay_ms = 100, + apply_delay_ms = 5000, -- Wait before applying code remote_provider = "claude", -- Default fallback provider }, } @@ -228,8 +232,19 @@ local function handle_worker_result(event, result) queue.complete(event.id) - -- Schedule patch application - M.schedule_patch_flush() + -- 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.agent.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 @@ -291,11 +306,23 @@ local function dispatch_next() 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() if applied > 0 or stale > 0 then pcall(function() @@ -307,15 +334,20 @@ function M.schedule_patch_flush() end) end else - -- Not safe yet, reschedule - pcall(function() - local logs = require("codetyper.agent.logs") - logs.add({ - type = "debug", - message = string.format("Patch flush deferred: %s", reason or "unknown"), - }) - end) - -- Will be retried on next InsertLeave or CursorHold + -- 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.agent.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 @@ -390,6 +422,15 @@ local function setup_autocmds() 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 diff --git a/lua/codetyper/agent/worker.lua b/lua/codetyper/agent/worker.lua index bd9f7be..c6b8a10 100644 --- a/lua/codetyper/agent/worker.lua +++ b/lua/codetyper/agent/worker.lua @@ -94,6 +94,15 @@ local function clean_response(response, filetype) 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]-@/", "") @@ -264,8 +273,35 @@ local function build_prompt(event) local scope_type = event.scope.type local scope_name = event.scope.name or "anonymous" - -- For replacement intents, provide the full scope to transform - if event.intent and intent_mod.is_replacement(event.intent) then + -- 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, + attached_content, + event.prompt_content, + scope_type + ) + -- 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: @@ -491,6 +527,18 @@ function M.complete(worker, response, error, usage) return end + -- Log the full raw LLM response (for debugging) + pcall(function() + local logs = require("codetyper.agent.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) diff --git a/lua/codetyper/autocmds.lua b/lua/codetyper/autocmds.lua index ea5f21c..3967c29 100644 --- a/lua/codetyper/autocmds.lua +++ b/lua/codetyper/autocmds.lua @@ -332,10 +332,7 @@ function M.check_for_closed_prompt() -- Clean prompt content (strip file references) local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content)) - -- Detect intent from prompt - local intent = intent_mod.detect(cleaned) - - -- Resolve scope in target file (use prompt position to find enclosing scope) + -- Resolve scope in target file FIRST (need it to adjust intent) local target_bufnr = vim.fn.bufnr(target_path) if target_bufnr == -1 then target_bufnr = bufnr @@ -354,6 +351,24 @@ function M.check_for_closed_prompt() } end + -- Detect intent from prompt + local intent = intent_mod.detect(cleaned) + + -- IMPORTANT: If prompt is inside a function/method and intent is "add", + -- override to "complete" since we're completing the function body + if scope and (scope.type == "function" or scope.type == "method") then + if intent.type == "add" or intent.action == "insert" or intent.action == "append" then + -- Override to complete the function instead of adding new code + intent = { + type = "complete", + scope_hint = "function", + confidence = intent.confidence, + action = "replace", + keywords = intent.keywords, + } + end + end + -- Determine priority based on intent local priority = 2 -- Normal if intent.type == "fix" or intent.type == "complete" then @@ -472,10 +487,7 @@ function M.check_all_prompts() -- Clean prompt content (strip file references) local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content)) - -- Detect intent from prompt - local intent = intent_mod.detect(cleaned) - - -- Resolve scope in target file + -- Resolve scope in target file FIRST (need it to adjust intent) local target_bufnr = vim.fn.bufnr(target_path) if target_bufnr == -1 then target_bufnr = bufnr -- Use current buffer if target not loaded @@ -494,6 +506,24 @@ function M.check_all_prompts() } end + -- Detect intent from prompt + local intent = intent_mod.detect(cleaned) + + -- IMPORTANT: If prompt is inside a function/method and intent is "add", + -- override to "complete" since we're completing the function body + if scope and (scope.type == "function" or scope.type == "method") then + if intent.type == "add" or intent.action == "insert" or intent.action == "append" then + -- Override to complete the function instead of adding new code + intent = { + type = "complete", + scope_hint = "function", + confidence = intent.confidence, + action = "replace", + keywords = intent.keywords, + } + end + end + -- Determine priority based on intent local priority = 2 if intent.type == "fix" or intent.type == "complete" then diff --git a/lua/codetyper/config.lua b/lua/codetyper/config.lua index 6cf3f44..b443cbc 100644 --- a/lua/codetyper/config.lua +++ b/lua/codetyper/config.lua @@ -46,6 +46,7 @@ local defaults = { escalation_threshold = 0.7, -- Below this confidence, escalate to remote LLM max_concurrent = 2, -- Maximum concurrent workers completion_delay_ms = 100, -- Wait after completion popup closes + apply_delay_ms = 5000, -- Wait before removing tags and applying code (ms) }, } diff --git a/lua/codetyper/init.lua b/lua/codetyper/init.lua index 928ac39..bb1f3d7 100644 --- a/lua/codetyper/init.lua +++ b/lua/codetyper/init.lua @@ -31,6 +31,7 @@ function M.setup(opts) local autocmds = require("codetyper.autocmds") local tree = require("codetyper.tree") local completion = require("codetyper.completion") + local logs_panel = require("codetyper.logs_panel") -- Register commands commands.setup() @@ -41,6 +42,9 @@ function M.setup(opts) -- Setup file reference completion completion.setup() + -- Setup logs panel (handles VimLeavePre cleanup) + logs_panel.setup() + -- Ensure .gitignore has coder files excluded gitignore.ensure_ignored() diff --git a/lua/codetyper/logs_panel.lua b/lua/codetyper/logs_panel.lua index 4cfedc3..789b127 100644 --- a/lua/codetyper/logs_panel.lua +++ b/lua/codetyper/logs_panel.lua @@ -274,38 +274,48 @@ function M.open() end --- Close the logs panel -function M.close() - if not state.is_open then +---@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 - logs.remove_listener(state.listener_id) + pcall(logs.remove_listener, state.listener_id) state.listener_id = nil end -- Remove queue listener if state.queue_listener_id then - queue.remove_listener(state.queue_listener_id) + pcall(queue.remove_listener, state.queue_listener_id) state.queue_listener_id = nil end - -- Close queue window - if state.queue_win and vim.api.nvim_win_is_valid(state.queue_win) then + -- 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 and vim.api.nvim_win_is_valid(state.win) then + 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 - -- Reset state - state.buf = nil - state.win = nil - state.queue_buf = nil - state.queue_win = nil state.is_open = false end @@ -331,4 +341,42 @@ function M.ensure_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/tests/spec/patch_spec.lua b/tests/spec/patch_spec.lua index e5dfcdd..418e11d 100644 --- a/tests/spec/patch_spec.lua +++ b/tests/spec/patch_spec.lua @@ -16,7 +16,7 @@ describe("patch", function() local id2 = patch.generate_id() assert.is_not.equals(id1, id2) - assert.is_true(id1:match("^patch_")) + assert.is_truthy(id1:match("^patch_")) end) end) @@ -302,4 +302,70 @@ describe("patch", function() assert.equals(1, #patch.get_pending()) end) end) + + describe("create_from_event", function() + it("should create patch with replace strategy for complete intent", function() + local event = { + id = "evt_123", + target_path = "/tmp/test.lua", + bufnr = 1, + range = { start_line = 5, end_line = 10 }, + scope_range = { start_line = 3, end_line = 12 }, + scope = { type = "function", name = "test_fn" }, + intent = { + type = "complete", + action = "replace", + confidence = 0.9, + keywords = {}, + }, + } + + local p = patch.create_from_event(event, "function code", 0.9) + + assert.equals("replace", p.injection_strategy) + assert.is_truthy(p.injection_range) + assert.equals(3, p.injection_range.start_line) + assert.equals(12, p.injection_range.end_line) + end) + + it("should create patch with append strategy for add intent", function() + local event = { + id = "evt_456", + target_path = "/tmp/test.lua", + bufnr = 1, + range = { start_line = 5, end_line = 10 }, + intent = { + type = "add", + action = "append", + confidence = 0.8, + keywords = {}, + }, + } + + local p = patch.create_from_event(event, "new function", 0.8) + + assert.equals("append", p.injection_strategy) + end) + + it("should create patch with insert strategy for insert action", function() + local event = { + id = "evt_789", + target_path = "/tmp/test.lua", + bufnr = 1, + range = { start_line = 5, end_line = 10 }, + intent = { + type = "add", + action = "insert", + confidence = 0.8, + keywords = {}, + }, + } + + local p = patch.create_from_event(event, "inserted code", 0.8) + + assert.equals("insert", p.injection_strategy) + assert.is_truthy(p.injection_range) + assert.equals(5, p.injection_range.start_line) + end) + end) end)