From 0600144768e98a0633ab1ff8a5ce820203a08e1b Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Tue, 13 Jan 2026 23:16:27 -0500 Subject: [PATCH] fixing the issues on the tags --- lua/codetyper/agent/context_modal.lua | 163 +++++++++++++++ lua/codetyper/agent/patch.lua | 124 ++++++++++-- lua/codetyper/agent/queue.lua | 21 +- lua/codetyper/agent/scheduler.lua | 50 +++++ lua/codetyper/agent/scope.lua | 36 +++- lua/codetyper/agent/worker.lua | 204 ++++++++++++++++++- lua/codetyper/autocmds.lua | 280 ++++++++++++++++++++++++-- lua/codetyper/commands.lua | 66 ++++++ lua/codetyper/completion.lua | 162 +++++++++++++++ lua/codetyper/init.lua | 4 + lua/codetyper/logs_panel.lua | 153 ++++++++++++-- lua/codetyper/parser.lua | 113 ++++++++++- tests/spec/parser_spec.lua | 66 ++++++ tests/spec/patch_spec.lua | 2 +- tests/spec/queue_spec.lua | 10 +- tests/spec/worker_spec.lua | 269 +++++++++++++++++++++++++ 16 files changed, 1647 insertions(+), 76 deletions(-) create mode 100644 lua/codetyper/agent/context_modal.lua create mode 100644 lua/codetyper/completion.lua create mode 100644 tests/spec/worker_spec.lua diff --git a/lua/codetyper/agent/context_modal.lua b/lua/codetyper/agent/context_modal.lua new file mode 100644 index 0000000..5493836 --- /dev/null +++ b/lua/codetyper/agent/context_modal.lua @@ -0,0 +1,163 @@ +---@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, +} + +--- 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 + callback(original_event, additional_context) + end +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) +function M.open(original_event, llm_response, callback) + -- 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 + + -- Add header showing what the LLM said + local header_lines = { + "-- LLM Response: --", + } + + -- 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 + + table.insert(header_lines, "") + table.insert(header_lines, "-- Enter additional context below (Ctrl-Enter to submit, Esc to cancel) --") + 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) + + -- 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.agent.logs") + logs.add({ + type = "info", + message = "Context modal opened - waiting for user input", + }) + 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 + +return M diff --git a/lua/codetyper/agent/patch.lua b/lua/codetyper/agent/patch.lua index 8e70058..42a08a1 100644 --- a/lua/codetyper/agent/patch.lua +++ b/lua/codetyper/agent/patch.lua @@ -231,6 +231,8 @@ function M.create_from_event(event, generated_code, confidence, strategy) 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, } end @@ -312,6 +314,89 @@ function M.mark_rejected(id, reason) 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 + --- Apply a patch to the target buffer ---@param patch PatchCandidate ---@return boolean success @@ -349,29 +434,34 @@ function M.apply(patch) -- Prepare code lines local code_lines = vim.split(patch.generated_code, "\n", { plain = true }) + -- FIRST: Remove the prompt tags from the buffer before applying code + -- This prevents the infinite loop where tags stay and get re-detected + local tags_removed = remove_prompt_tags(target_bufnr) + + pcall(function() + if tags_removed > 0 then + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = string.format("Removed %d prompt tag(s) from buffer", tags_removed), + }) + end + end) + + -- Recalculate line count after tag removal + local line_count = vim.api.nvim_buf_line_count(target_bufnr) + -- Apply based on strategy local ok, err = pcall(function() if patch.injection_strategy == "replace" and patch.injection_range then - -- Replace specific range - vim.api.nvim_buf_set_lines( - target_bufnr, - patch.injection_range.start_line - 1, - patch.injection_range.end_line, - false, - code_lines - ) + -- 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) elseif patch.injection_strategy == "insert" and patch.injection_range then - -- Insert at specific line - vim.api.nvim_buf_set_lines( - target_bufnr, - patch.injection_range.start_line - 1, - patch.injection_range.start_line - 1, - false, - code_lines - ) + -- Insert at end since original position might have shifted + vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines) else -- Default: append to end - local line_count = vim.api.nvim_buf_line_count(target_bufnr) vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines) end end) diff --git a/lua/codetyper/agent/queue.lua b/lua/codetyper/agent/queue.lua index 9eb2b61..a17e004 100644 --- a/lua/codetyper/agent/queue.lua +++ b/lua/codetyper/agent/queue.lua @@ -6,6 +6,11 @@ local M = {} +---@class AttachedFile +---@field path string Relative path as referenced in prompt +---@field full_path string Absolute path to the file +---@field content string File content + ---@class PromptEvent ---@field id string Unique event ID ---@field bufnr number Source buffer number @@ -16,7 +21,7 @@ local M = {} ---@field prompt_content string Cleaned prompt text ---@field target_path string Target file for injection ---@field priority number Priority (1=high, 2=normal, 3=low) ----@field status string "pending"|"processing"|"completed"|"escalated"|"cancelled" +---@field status string "pending"|"processing"|"completed"|"escalated"|"cancelled"|"needs_context"|"failed" ---@field attempt_count number Number of processing attempts ---@field worker_type string|nil LLM provider used ("ollama"|"claude"|etc) ---@field created_at number System time when created @@ -24,6 +29,7 @@ local M = {} ---@field scope ScopeInfo|nil Resolved scope (function/class/file) ---@field scope_text string|nil Text of the resolved scope ---@field scope_range {start_line: number, end_line: number}|nil Range of scope in target +---@field attached_files AttachedFile[]|nil Files attached via @filename syntax --- Internal state ---@type PromptEvent[] @@ -383,16 +389,21 @@ function M.clear(status) notify_listeners("update", nil) end ---- Cleanup completed/cancelled events older than max_age seconds +--- Cleanup completed/cancelled/failed events older than max_age seconds ---@param max_age number Maximum age in seconds (default: 300) function M.cleanup(max_age) max_age = max_age or 300 local now = os.time() + local terminal_statuses = { + completed = true, + cancelled = true, + failed = true, + needs_context = true, + } local i = 1 while i <= #queue do local event = queue[i] - if (event.status == "completed" or event.status == "cancelled") - and (now - event.created_at) > max_age then + if terminal_statuses[event.status] and (now - event.created_at) > max_age then table.remove(queue, i) else i = i + 1 @@ -410,6 +421,8 @@ function M.stats() completed = 0, cancelled = 0, escalated = 0, + failed = 0, + needs_context = 0, } for _, event in ipairs(queue) do local s = event.status diff --git a/lua/codetyper/agent/scheduler.lua b/lua/codetyper/agent/scheduler.lua index fa88e1e..b8decf9 100644 --- a/lua/codetyper/agent/scheduler.lua +++ b/lua/codetyper/agent/scheduler.lua @@ -10,6 +10,7 @@ local queue = require("codetyper.agent.queue") local patch = require("codetyper.agent.patch") local worker = require("codetyper.agent.worker") local confidence_mod = require("codetyper.agent.confidence") +local context_modal = require("codetyper.agent.context_modal") --- Scheduler state local state = { @@ -118,10 +119,59 @@ local function get_primary_provider() return "claude" 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) + -- 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 + + -- Log the retry + pcall(function() + local logs = require("codetyper.agent.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 + --- 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.agent.logs") + logs.add({ + type = "info", + message = string.format("Event %s: LLM needs more context, opening modal", event.id), + }) + end) + + -- Open the context modal + 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 diff --git a/lua/codetyper/agent/scope.lua b/lua/codetyper/agent/scope.lua index f530031..b9c38c5 100644 --- a/lua/codetyper/agent/scope.lua +++ b/lua/codetyper/agent/scope.lua @@ -75,17 +75,43 @@ local block_nodes = { ---@param bufnr number ---@return boolean function M.has_treesitter(bufnr) - local ok, parsers = pcall(require, "nvim-treesitter.parsers") - if not ok then - return false + -- 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 - local lang = parsers.get_buf_lang(bufnr) + -- 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 - return parsers.has_parser(lang) + -- 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 diff --git a/lua/codetyper/agent/worker.lua b/lua/codetyper/agent/worker.lua index 8668d75..bd9f7be 100644 --- a/lua/codetyper/agent/worker.lua +++ b/lua/codetyper/agent/worker.lua @@ -31,6 +31,137 @@ local confidence = require("codetyper.agent.confidence") --- Worker ID counter local worker_counter = 0 +--- Patterns that indicate LLM needs more context (must be near start of response) +local context_needed_patterns = { + "^%s*i need more context", + "^%s*i'm sorry.-i need more", + "^%s*i apologize.-i need more", + "^%s*could you provide more context", + "^%s*could you please provide more", + "^%s*can you clarify", + "^%s*please provide more context", + "^%s*more information needed", + "^%s*not enough context", + "^%s*i don't have enough", + "^%s*unclear what you", + "^%s*what do you mean by", +} + +--- 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 + +--- 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 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]-@/", "") + + -- 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 = {} @@ -63,6 +194,28 @@ local function get_client(worker_type) 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 + --- Build prompt for code generation ---@param event table PromptEvent ---@return string prompt @@ -83,6 +236,9 @@ local function build_prompt(event) local filetype = vim.fn.fnamemodify(event.target_path or "", ":e") + -- Format attached files + local attached_content = format_attached_files(event.attached_files) + -- Build context with scope information local context = { target_path = event.target_path, @@ -92,6 +248,7 @@ local function build_prompt(event) scope_text = event.scope_text, scope_range = event.scope_range, intent = event.intent, + attached_files = event.attached_files, } -- Build the actual prompt based on intent and scope @@ -115,7 +272,7 @@ local function build_prompt(event) ```%s %s ``` - +%s User request: %s Return the complete transformed %s. Output only code, no explanations.]], @@ -124,6 +281,7 @@ Return the complete transformed %s. Output only code, no explanations.]], filetype, filetype, event.scope_text, + attached_content, event.prompt_content, scope_type ) @@ -135,7 +293,7 @@ Return the complete transformed %s. Output only code, no explanations.]], ```%s %s ``` - +%s User request: %s Output only the code to insert, no explanations.]], @@ -143,6 +301,7 @@ Output only the code to insert, no explanations.]], scope_name, filetype, event.scope_text, + attached_content, event.prompt_content ) end @@ -154,7 +313,7 @@ Output only the code to insert, no explanations.]], ```%s %s ``` - +%s User request: %s Output only code, no explanations.]], @@ -162,6 +321,7 @@ Output only code, no explanations.]], filetype, filetype, target_content:sub(1, 4000), -- Limit context size + attached_content, event.prompt_content ) end @@ -303,8 +463,40 @@ function M.complete(worker, response, error, usage) return end - -- Score confidence - local conf_score, breakdown = confidence.score(response, worker.event.prompt_content) + -- 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.agent.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 + + -- 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 @@ -326,7 +518,7 @@ function M.complete(worker, response, error, usage) worker.callback({ success = true, - response = response, + response = cleaned_response, error = nil, confidence = conf_score, confidence_breakdown = breakdown, diff --git a/lua/codetyper/autocmds.lua b/lua/codetyper/autocmds.lua index 09e2aa3..ea5f21c 100644 --- a/lua/codetyper/autocmds.lua +++ b/lua/codetyper/autocmds.lua @@ -40,19 +40,61 @@ end function M.setup() local group = vim.api.nvim_create_augroup(AUGROUP, { clear = true }) - -- Auto-save coder file when leaving insert mode + -- Auto-check for closed prompts when leaving insert mode (works on ALL files) vim.api.nvim_create_autocmd("InsertLeave", { group = group, - pattern = "*.coder.*", + pattern = "*", callback = function() - -- Auto-save the coder file - if vim.bo.modified then + -- Skip special buffers + local buftype = vim.bo.buftype + if buftype ~= "" then + return + end + -- Auto-save coder files only + local filepath = vim.fn.expand("%:p") + if utils.is_coder_file(filepath) and vim.bo.modified then vim.cmd("silent! write") end -- Check for closed prompts and auto-process M.check_for_closed_prompt() end, - desc = "Auto-save and check for closed prompt tags", + desc = "Check for closed prompt tags on InsertLeave", + }) + + -- Auto-process prompts when entering normal mode (works on ALL files) + vim.api.nvim_create_autocmd("ModeChanged", { + group = group, + pattern = "*:n", + callback = function() + -- Skip special buffers + local buftype = vim.bo.buftype + if buftype ~= "" then + return + end + -- Slight delay to let buffer settle + vim.defer_fn(function() + M.check_all_prompts() + end, 50) + end, + desc = "Auto-process closed prompts when entering normal mode", + }) + + -- Also check on CursorHold as backup (works on ALL files) + vim.api.nvim_create_autocmd("CursorHold", { + group = group, + pattern = "*", + callback = function() + -- Skip special buffers + local buftype = vim.bo.buftype + if buftype ~= "" then + return + end + local mode = vim.api.nvim_get_mode().mode + if mode == "n" then + M.check_all_prompts() + end + end, + desc = "Auto-process closed prompts when idle in normal mode", }) -- Auto-set filetype for coder files based on extension @@ -172,12 +214,59 @@ local function get_config_safe() return config end ---- Check if the buffer has a newly closed prompt and auto-process +--- Read attached files from prompt content +---@param prompt_content string Prompt content +---@param base_path string Base path to resolve relative file paths +---@return table[] attached_files List of {path, content} tables +local function read_attached_files(prompt_content, base_path) + local parser = require("codetyper.parser") + local file_refs = parser.extract_file_references(prompt_content) + local attached = {} + local cwd = vim.fn.getcwd() + local base_dir = vim.fn.fnamemodify(base_path, ":h") + + for _, ref in ipairs(file_refs) do + local file_path = nil + + -- Try resolving relative to cwd first + local cwd_path = cwd .. "/" .. ref + if utils.file_exists(cwd_path) then + file_path = cwd_path + else + -- Try resolving relative to base file directory + local rel_path = base_dir .. "/" .. ref + if utils.file_exists(rel_path) then + file_path = rel_path + end + end + + if file_path then + local content = utils.read_file(file_path) + if content then + table.insert(attached, { + path = ref, + full_path = file_path, + content = content, + }) + end + end + end + + return attached +end + +--- Check if the buffer has a newly closed prompt and auto-process (works on ANY file) function M.check_for_closed_prompt() local config = get_config_safe() local parser = require("codetyper.parser") local bufnr = vim.api.nvim_get_current_buf() + local current_file = vim.fn.expand("%:p") + + -- Skip if no file + if current_file == "" then + return + end -- Get current line local cursor = vim.api.nvim_win_get_cursor(0) @@ -218,6 +307,10 @@ function M.check_for_closed_prompt() local patch_mod = require("codetyper.agent.patch") local intent_mod = require("codetyper.agent.intent") local scope_mod = require("codetyper.agent.scope") + local logs_panel = require("codetyper.logs_panel") + + -- Open logs panel to show progress + logs_panel.ensure_open() -- Take buffer snapshot local snapshot = patch_mod.snapshot_buffer(bufnr, { @@ -225,33 +318,40 @@ function M.check_for_closed_prompt() end_line = prompt.end_line, }) - -- Get target path - local current_file = vim.fn.expand("%:p") - local target_path = utils.get_target_path(current_file) + -- Get target path - for coder files, get the target; for regular files, use self + local target_path + if utils.is_coder_file(current_file) then + target_path = utils.get_target_path(current_file) + else + target_path = current_file + end - -- Clean prompt content - local cleaned = parser.clean_prompt(prompt.content) + -- Read attached files before cleaning + local attached_files = read_attached_files(prompt.content, current_file) + + -- 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) local target_bufnr = vim.fn.bufnr(target_path) + if target_bufnr == -1 then + target_bufnr = bufnr + end + local scope = nil local scope_text = nil local scope_range = nil - if target_bufnr ~= -1 then - -- Find scope at the corresponding line in target - -- Use the prompt's line position as reference - scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1) - if scope and scope.type ~= "file" then - scope_text = scope.text - scope_range = { - start_line = scope.range.start_row, - end_line = scope.range.end_row, - } - end + scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1) + if scope and scope.type ~= "file" then + scope_text = scope.text + scope_range = { + start_line = scope.range.start_row, + end_line = scope.range.end_row, + } end -- Determine priority based on intent @@ -279,6 +379,7 @@ function M.check_for_closed_prompt() scope = scope, scope_text = scope_text, scope_range = scope_range, + attached_files = attached_files, }) local scope_info = scope and scope.type ~= "file" @@ -300,6 +401,141 @@ function M.check_for_closed_prompt() end end +--- Check and process all closed prompts in the buffer (works on ANY file) +function M.check_all_prompts() + local parser = require("codetyper.parser") + local bufnr = vim.api.nvim_get_current_buf() + local current_file = vim.fn.expand("%:p") + + -- Skip if no file + if current_file == "" then + return + end + + -- Find all prompts in buffer + local prompts = parser.find_prompts_in_buffer(bufnr) + + if #prompts == 0 then + return + end + + -- Check if scheduler is enabled + local codetyper = require("codetyper") + local ct_config = codetyper.get_config() + local scheduler_enabled = ct_config and ct_config.scheduler and ct_config.scheduler.enabled + + if not scheduler_enabled then + return + end + + for _, prompt in ipairs(prompts) do + if prompt.content and prompt.content ~= "" then + -- Generate unique key for this prompt + local prompt_key = get_prompt_key(bufnr, prompt) + + -- Skip if already processed + if processed_prompts[prompt_key] then + goto continue + end + + -- Mark as processed + processed_prompts[prompt_key] = true + + -- Process this prompt + vim.schedule(function() + local queue = require("codetyper.agent.queue") + local patch_mod = require("codetyper.agent.patch") + local intent_mod = require("codetyper.agent.intent") + local scope_mod = require("codetyper.agent.scope") + local logs_panel = require("codetyper.logs_panel") + + -- Open logs panel to show progress + logs_panel.ensure_open() + + -- Take buffer snapshot + local snapshot = patch_mod.snapshot_buffer(bufnr, { + start_line = prompt.start_line, + end_line = prompt.end_line, + }) + + -- Get target path - for coder files, get the target; for regular files, use self + local target_path + if utils.is_coder_file(current_file) then + target_path = utils.get_target_path(current_file) + else + target_path = current_file + end + + -- Read attached files before cleaning + local attached_files = read_attached_files(prompt.content, current_file) + + -- 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 + local target_bufnr = vim.fn.bufnr(target_path) + if target_bufnr == -1 then + target_bufnr = bufnr -- Use current buffer if target not loaded + end + + local scope = nil + local scope_text = nil + local scope_range = nil + + scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1) + if scope and scope.type ~= "file" then + scope_text = scope.text + scope_range = { + start_line = scope.range.start_row, + end_line = scope.range.end_row, + } + end + + -- Determine priority based on intent + local priority = 2 + if intent.type == "fix" or intent.type == "complete" then + priority = 1 + elseif intent.type == "test" or intent.type == "document" then + priority = 3 + end + + -- Enqueue the event + queue.enqueue({ + id = queue.generate_id(), + bufnr = bufnr, + range = { start_line = prompt.start_line, end_line = prompt.end_line }, + timestamp = os.clock(), + changedtick = snapshot.changedtick, + content_hash = snapshot.content_hash, + prompt_content = cleaned, + target_path = target_path, + priority = priority, + status = "pending", + attempt_count = 0, + intent = intent, + scope = scope, + scope_text = scope_text, + scope_range = scope_range, + attached_files = attached_files, + }) + + local scope_info = scope and scope.type ~= "file" + and string.format(" [%s: %s]", scope.type, scope.name or "anonymous") + or "" + utils.notify( + string.format("Prompt queued: %s%s", intent.type, scope_info), + vim.log.levels.INFO + ) + end) + + ::continue:: + end + end +end + --- Reset processed prompts for a buffer (useful for re-processing) ---@param bufnr? number Buffer number (default: current) function M.reset_processed(bufnr) diff --git a/lua/codetyper/commands.lua b/lua/codetyper/commands.lua index 34a11d9..c0a8956 100644 --- a/lua/codetyper/commands.lua +++ b/lua/codetyper/commands.lua @@ -293,6 +293,60 @@ local function cmd_logs_toggle() logs_panel.toggle() end +--- Show scheduler status and queue info +local function cmd_queue_status() + local scheduler = require("codetyper.agent.scheduler") + local queue = require("codetyper.agent.queue") + local parser = require("codetyper.parser") + + local status = scheduler.status() + local bufnr = vim.api.nvim_get_current_buf() + local filepath = vim.fn.expand("%:p") + + local lines = { + "Scheduler Status", + "================", + "", + "Running: " .. (status.running and "yes" or "NO"), + "Paused: " .. (status.paused and "yes" or "no"), + "Active Workers: " .. status.active_workers, + "", + "Queue Stats:", + " Pending: " .. status.queue_stats.pending, + " Processing: " .. status.queue_stats.processing, + " Completed: " .. status.queue_stats.completed, + " Cancelled: " .. status.queue_stats.cancelled, + "", + } + + -- Check current buffer for prompts + if filepath ~= "" then + local prompts = parser.find_prompts_in_buffer(bufnr) + table.insert(lines, "Current Buffer: " .. vim.fn.fnamemodify(filepath, ":t")) + table.insert(lines, " Prompts found: " .. #prompts) + for i, p in ipairs(prompts) do + local preview = p.content:sub(1, 30):gsub("\n", " ") + table.insert(lines, string.format(" %d. Line %d: %s...", i, p.start_line, preview)) + end + end + + utils.notify(table.concat(lines, "\n")) +end + +--- Manually trigger queue processing for current buffer +local function cmd_queue_process() + local autocmds = require("codetyper.autocmds") + local logs_panel = require("codetyper.logs_panel") + + -- Open logs panel to show progress + logs_panel.open() + + -- Check all prompts in current buffer + autocmds.check_all_prompts() + + utils.notify("Triggered queue processing for current buffer") +end + --- Switch focus between coder and target windows local function cmd_focus() if not window.is_open() then @@ -685,6 +739,8 @@ local function coder_cmd(args) ["agent-stop"] = cmd_agent_stop, ["type-toggle"] = cmd_type_toggle, ["logs-toggle"] = cmd_logs_toggle, + ["queue-status"] = cmd_queue_status, + ["queue-process"] = cmd_queue_process, } local cmd_fn = commands[subcommand] @@ -707,6 +763,7 @@ function M.setup() "transform", "transform-cursor", "agent", "agent-close", "agent-toggle", "agent-stop", "type-toggle", "logs-toggle", + "queue-status", "queue-process", } end, desc = "Codetyper.nvim commands", @@ -794,6 +851,15 @@ function M.setup() autocmds.open_coder_companion() end, { desc = "Open coder companion for current file" }) + -- Queue commands + vim.api.nvim_create_user_command("CoderQueueStatus", function() + cmd_queue_status() + end, { desc = "Show scheduler and queue status" }) + + vim.api.nvim_create_user_command("CoderQueueProcess", function() + cmd_queue_process() + end, { desc = "Manually trigger queue processing" }) + -- Setup default keymaps M.setup_keymaps() end diff --git a/lua/codetyper/completion.lua b/lua/codetyper/completion.lua new file mode 100644 index 0000000..20de177 --- /dev/null +++ b/lua/codetyper/completion.lua @@ -0,0 +1,162 @@ +---@mod codetyper.completion Insert mode completion for file references +--- +--- Provides completion for @filename inside /@ @/ tags. + +local M = {} + +local parser = require("codetyper.parser") +local utils = require("codetyper.utils") + +--- Get list of files for completion +---@param prefix string Prefix to filter files +---@return table[] List of completion items +local function get_file_completions(prefix) + local cwd = vim.fn.getcwd() + local files = {} + + -- Use vim.fn.glob to find files matching the prefix + local pattern = prefix .. "*" + + -- Search in current directory + local matches = vim.fn.glob(cwd .. "/" .. pattern, false, true) + + -- Search with ** for all subdirectories + local deep_matches = vim.fn.glob(cwd .. "/**/" .. pattern, false, true) + for _, m in ipairs(deep_matches) do + table.insert(matches, m) + end + + -- Also search specific directories if prefix doesn't have path + if not prefix:find("/") then + local search_dirs = { "src", "lib", "lua", "app", "components", "utils", "tests" } + for _, dir in ipairs(search_dirs) do + local dir_path = cwd .. "/" .. dir + if vim.fn.isdirectory(dir_path) == 1 then + local dir_matches = vim.fn.glob(dir_path .. "/**/" .. pattern, false, true) + for _, m in ipairs(dir_matches) do + table.insert(matches, m) + end + end + end + end + + -- Convert to relative paths and deduplicate + local seen = {} + for _, match in ipairs(matches) do + local rel_path = match:sub(#cwd + 2) -- Remove cwd/ prefix + -- Skip directories, coder files, and hidden/generated files + if vim.fn.isdirectory(match) == 0 + and not utils.is_coder_file(match) + and not rel_path:match("^%.") + and not rel_path:match("node_modules") + and not rel_path:match("%.git/") + and not rel_path:match("dist/") + and not rel_path:match("build/") + and not seen[rel_path] + then + seen[rel_path] = true + table.insert(files, { + word = rel_path, + abbr = rel_path, + kind = "File", + menu = "[ref]", + }) + end + end + + -- Sort by length (shorter paths first) + table.sort(files, function(a, b) + return #a.word < #b.word + end) + + -- Limit results + local result = {} + for i = 1, math.min(#files, 15) do + result[i] = files[i] + end + + return result +end + +--- Show file completion popup +function M.show_file_completion() + -- Check if we're in an open prompt tag + local is_inside = parser.is_cursor_in_open_tag() + if not is_inside then + return false + end + + -- Get the prefix being typed + local prefix = parser.get_file_ref_prefix() + if prefix == nil then + return false + end + + -- Get completions + local items = get_file_completions(prefix) + + if #items == 0 then + -- Try with empty prefix to show all files + items = get_file_completions("") + end + + if #items > 0 then + -- Calculate start column (position right after @) + local cursor = vim.api.nvim_win_get_cursor(0) + local col = cursor[2] - #prefix + 1 -- 1-indexed for complete() + + -- Show completion popup + vim.fn.complete(col, items) + return true + end + + return false +end + +--- Setup completion for file references (works on ALL files) +function M.setup() + local group = vim.api.nvim_create_augroup("CoderCompletion", { clear = true }) + + -- Trigger completion on @ in insert mode (works on ALL files) + vim.api.nvim_create_autocmd("InsertCharPre", { + group = group, + pattern = "*", + callback = function() + -- Skip special buffers + if vim.bo.buftype ~= "" then + return + end + + if vim.v.char == "@" then + -- Schedule completion popup after the @ is inserted + vim.schedule(function() + -- Check we're in an open tag + local is_inside = parser.is_cursor_in_open_tag() + if not is_inside then + return + end + + -- Check we're not typing @/ (closing tag) + local cursor = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_get_current_line() + local next_char = line:sub(cursor[2] + 2, cursor[2] + 2) + + if next_char == "/" then + return + end + + -- Show file completion + M.show_file_completion() + end) + end + end, + desc = "Trigger file completion on @ inside prompt tags", + }) + + -- Also allow manual trigger with style keybinding in insert mode + vim.keymap.set("i", "@", function() + M.show_file_completion() + end, { silent = true, desc = "Coder: Complete file reference" }) +end + +return M diff --git a/lua/codetyper/init.lua b/lua/codetyper/init.lua index b4c262c..928ac39 100644 --- a/lua/codetyper/init.lua +++ b/lua/codetyper/init.lua @@ -30,6 +30,7 @@ function M.setup(opts) local gitignore = require("codetyper.gitignore") local autocmds = require("codetyper.autocmds") local tree = require("codetyper.tree") + local completion = require("codetyper.completion") -- Register commands commands.setup() @@ -37,6 +38,9 @@ function M.setup(opts) -- Setup autocommands autocmds.setup() + -- Setup file reference completion + completion.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 501eca4..4cfedc3 100644 --- a/lua/codetyper/logs_panel.lua +++ b/lua/codetyper/logs_panel.lua @@ -5,25 +5,34 @@ local M = {} local logs = require("codetyper.agent.logs") +local queue = require("codetyper.agent.queue") ---@class LogsPanelState ----@field buf number|nil Buffer ----@field win number|nil Window +---@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 width +--- Fixed dimensions local LOGS_WIDTH = 60 +local QUEUE_HEIGHT = 8 --- Add a log entry to the buffer ---@param entry table Log entry @@ -52,10 +61,10 @@ local function add_log_entry(entry) vim.bo[state.buf].modifiable = true local formatted = logs.format_entry(entry) - local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false) - local line_num = #lines + 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 }) + vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, formatted_lines) -- Apply highlighting based on level local hl_map = { @@ -68,7 +77,9 @@ local function add_log_entry(entry) } local hl = hl_map[entry.level] or "Normal" - vim.api.nvim_buf_add_highlight(state.buf, ns_logs, hl, line_num, 0, -1) + 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 @@ -97,6 +108,77 @@ local function update_title() 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 @@ -106,7 +188,7 @@ function M.open() -- Clear previous logs logs.clear() - -- Create buffer + -- Create logs buffer state.buf = vim.api.nvim_create_buf(false, true) vim.bo[state.buf].buftype = "nofile" vim.bo[state.buf].bufhidden = "hide" @@ -118,7 +200,7 @@ function M.open() vim.api.nvim_win_set_buf(state.win, state.buf) vim.api.nvim_win_set_width(state.win, LOGS_WIDTH) - -- Window options + -- Window options for logs vim.wo[state.win].number = false vim.wo[state.win].relativenumber = false vim.wo[state.win].signcolumn = "no" @@ -127,7 +209,7 @@ function M.open() vim.wo[state.win].winfixwidth = true vim.wo[state.win].cursorline = false - -- Set initial content + -- Set initial content for logs vim.bo[state.buf].modifiable = true vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, { "Generation Logs", @@ -136,11 +218,37 @@ function M.open() }) vim.bo[state.buf].modifiable = false - -- Setup keymaps + -- 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) @@ -149,6 +257,14 @@ function M.open() 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 @@ -169,7 +285,18 @@ function M.close() state.listener_id = nil end - -- Close window + -- Remove queue listener + if state.queue_listener_id then + 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 + pcall(vim.api.nvim_win_close, state.queue_win, true) + end + + -- Close logs window if state.win and vim.api.nvim_win_is_valid(state.win) then pcall(vim.api.nvim_win_close, state.win, true) end @@ -177,6 +304,8 @@ function M.close() -- Reset state state.buf = nil state.win = nil + state.queue_buf = nil + state.queue_win = nil state.is_open = false end diff --git a/lua/codetyper/parser.lua b/lua/codetyper/parser.lua index e476c4d..fe5978f 100644 --- a/lua/codetyper/parser.lua +++ b/lua/codetyper/parser.lua @@ -4,6 +4,25 @@ local M = {} local utils = require("codetyper.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 = "@/", + } + } +end + --- Find all prompts in buffer content ---@param content string Buffer content ---@param open_tag string Opening tag @@ -72,8 +91,7 @@ end ---@param bufnr number Buffer number ---@return CoderPrompt[] List of found prompts function M.find_prompts_in_buffer(bufnr) - local codetyper = require("codetyper") - local config = codetyper.get_config() + local config = get_config_safe() local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local content = table.concat(lines, "\n") @@ -165,8 +183,7 @@ end ---@return boolean function M.has_unclosed_prompts(bufnr) bufnr = bufnr or vim.api.nvim_get_current_buf() - local codetyper = require("codetyper") - local config = codetyper.get_config() + local config = get_config_safe() local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local content = table.concat(lines, "\n") @@ -180,4 +197,92 @@ function M.has_unclosed_prompts(bufnr) return open_count > close_count end +--- Extract file references from prompt content +--- Matches @filename patterns but NOT @/ (closing tag) +---@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 +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%._%-/]*)", "") +end + +--- Check if cursor is inside an unclosed prompt tag +---@param bufnr? number Buffer number (default: current) +---@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() + + 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 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 + + 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() + + 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) + + -- 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 + + return prefix +end + return M diff --git a/tests/spec/parser_spec.lua b/tests/spec/parser_spec.lua index e60f591..511328d 100644 --- a/tests/spec/parser_spec.lua +++ b/tests/spec/parser_spec.lua @@ -138,4 +138,70 @@ multiline @/ assert.is_false(parser.has_closing_tag("", "@/")) end) end) + + describe("extract_file_references", function() + it("should extract single file reference", function() + local files = parser.extract_file_references("fix this @utils.ts") + assert.equals(1, #files) + assert.equals("utils.ts", files[1]) + end) + + it("should extract multiple file references", function() + local files = parser.extract_file_references("use @config.ts and @helpers.lua") + assert.equals(2, #files) + assert.equals("config.ts", files[1]) + assert.equals("helpers.lua", files[2]) + end) + + it("should extract file paths with directories", function() + local files = parser.extract_file_references("check @src/utils/helpers.ts") + assert.equals(1, #files) + assert.equals("src/utils/helpers.ts", files[1]) + end) + + it("should NOT extract closing tag @/", function() + local files = parser.extract_file_references("fix this @/") + assert.equals(0, #files) + end) + + it("should handle mixed content with closing tag", function() + local files = parser.extract_file_references("use @config.ts to fix @/") + assert.equals(1, #files) + assert.equals("config.ts", files[1]) + end) + + it("should return empty table when no file refs", function() + local files = parser.extract_file_references("just some text") + assert.equals(0, #files) + end) + + it("should handle relative paths", function() + local files = parser.extract_file_references("check @../config.json") + assert.equals(1, #files) + assert.equals("../config.json", files[1]) + end) + end) + + describe("strip_file_references", function() + it("should remove single file reference", function() + local result = parser.strip_file_references("fix this @utils.ts please") + assert.equals("fix this please", result) + end) + + it("should remove multiple file references", function() + local result = parser.strip_file_references("use @config.ts and @helpers.lua") + assert.equals("use and ", result) + end) + + it("should NOT remove closing tag", function() + local result = parser.strip_file_references("fix this @/") + -- @/ should remain since it's the closing tag pattern + assert.is_true(result:find("@/") ~= nil) + end) + + it("should handle paths with directories", function() + local result = parser.strip_file_references("check @src/utils.ts here") + assert.equals("check here", result) + end) + end) end) diff --git a/tests/spec/patch_spec.lua b/tests/spec/patch_spec.lua index 84fedd5..e5dfcdd 100644 --- a/tests/spec/patch_spec.lua +++ b/tests/spec/patch_spec.lua @@ -163,7 +163,7 @@ describe("patch", function() local found = patch.get(p.id) - assert.is_not.nil(found) + assert.is_not_nil(found) assert.equals(p.id, found.id) end) diff --git a/tests/spec/queue_spec.lua b/tests/spec/queue_spec.lua index 8ebaee6..df9c08e 100644 --- a/tests/spec/queue_spec.lua +++ b/tests/spec/queue_spec.lua @@ -49,7 +49,7 @@ describe("queue", function() local enqueued = queue.enqueue(event) - assert.is_not.nil(enqueued.id) + assert.is_not_nil(enqueued.id) assert.equals("pending", enqueued.status) assert.equals(1, queue.size()) end) @@ -98,7 +98,7 @@ describe("queue", function() local enqueued = queue.enqueue(event) - assert.is_not.nil(enqueued.content_hash) + assert.is_not_nil(enqueued.content_hash) end) end) @@ -118,7 +118,7 @@ describe("queue", function() local event = queue.dequeue() - assert.is_not.nil(event) + assert.is_not_nil(event) assert.equals("processing", event.status) end) @@ -157,7 +157,7 @@ describe("queue", function() local event1 = queue.peek() local event2 = queue.peek() - assert.is_not.nil(event1) + assert.is_not_nil(event1) assert.equals(event1.id, event2.id) assert.equals("pending", event1.status) end) @@ -174,7 +174,7 @@ describe("queue", function() local event = queue.get(enqueued.id) - assert.is_not.nil(event) + assert.is_not_nil(event) assert.equals(enqueued.id, event.id) end) diff --git a/tests/spec/worker_spec.lua b/tests/spec/worker_spec.lua new file mode 100644 index 0000000..58e61e8 --- /dev/null +++ b/tests/spec/worker_spec.lua @@ -0,0 +1,269 @@ +---@diagnostic disable: undefined-global +-- Tests for lua/codetyper/agent/worker.lua response cleaning + +-- We need to test the clean_response function +-- Since it's local, we'll create a test module that exposes it + +describe("worker response cleaning", function() + -- Mock the clean_response function behavior directly + local function clean_response(response) + if not response then + return "" + end + + local cleaned = response + + -- Remove the original prompt tags /@ ... @/ if they appear in output + -- Use [%s%S] to match any character including newlines + cleaned = cleaned:gsub("/@[%s%S]-@/", "") + + -- Try to extract code from markdown code blocks + local code_block = cleaned:match("```[%w]*\n(.-)\n```") + if not code_block then + code_block = cleaned:match("```[%w]*(.-)\n```") + end + if not code_block then + code_block = cleaned:match("```(.-)```") + end + + if code_block then + cleaned = code_block + else + 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 + + 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 + + cleaned = cleaned:gsub("^```[%w]*\n?", "") + cleaned = cleaned:gsub("\n?```$", "") + cleaned = cleaned:match("^%s*(.-)%s*$") or cleaned + + return cleaned + end + + describe("clean_response", function() + it("should extract code from markdown code blocks", function() + local response = [[```java +public void test() { + System.out.println("Hello"); +} +```]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("public void test") ~= nil) + assert.is_true(cleaned:find("```") == nil) + end) + + it("should handle code blocks without language", function() + local response = [[``` +function test() + print("hello") +end +```]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("function test") ~= nil) + assert.is_true(cleaned:find("```") == nil) + end) + + it("should remove single-line prompt tags from response", function() + local response = [[/@ create a function @/ +function test() end]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("/@") == nil) + assert.is_true(cleaned:find("@/") == nil) + assert.is_true(cleaned:find("function test") ~= nil) + end) + + it("should remove multiline prompt tags from response", function() + local response = [[function test() end +/@ +create a function +that does something +@/ +function another() end]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("/@") == nil) + assert.is_true(cleaned:find("@/") == nil) + assert.is_true(cleaned:find("function test") ~= nil) + assert.is_true(cleaned:find("function another") ~= nil) + end) + + it("should remove multiple prompt tags from response", function() + local response = [[function test() end +/@ first prompt @/ +/@ second +multiline prompt @/ +function another() end]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("/@") == nil) + assert.is_true(cleaned:find("@/") == nil) + assert.is_true(cleaned:find("function test") ~= nil) + assert.is_true(cleaned:find("function another") ~= nil) + end) + + it("should remove apology prefixes", function() + local response = [[I'm sorry for any confusion. +Here is the code: +function test() end]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("sorry") == nil or cleaned:find("function test") ~= nil) + end) + + it("should remove trailing explanations", function() + local response = [[function test() end +This code does something useful.]] + local cleaned = clean_response(response) + -- The ending pattern should be removed + assert.is_true(cleaned:find("function test") ~= nil) + end) + + it("should handle empty response", function() + local cleaned = clean_response("") + assert.equals("", cleaned) + end) + + it("should handle nil response", function() + local cleaned = clean_response(nil) + assert.equals("", cleaned) + end) + + it("should preserve clean code", function() + local response = [[function test() + return true +end]] + local cleaned = clean_response(response) + assert.equals(response, cleaned) + end) + + it("should handle complex markdown with explanation", function() + local response = [[Here is the implementation: + +```lua +local function validate(input) + if not input then + return false + end + return true +end +``` + +Let me know if you need any changes.]] + local cleaned = clean_response(response) + assert.is_true(cleaned:find("local function validate") ~= nil) + assert.is_true(cleaned:find("```") == nil) + assert.is_true(cleaned:find("Let me know") == nil) + end) + end) + + describe("needs_more_context detection", function() + local context_needed_patterns = { + "^%s*i need more context", + "^%s*i'm sorry.-i need more", + "^%s*i apologize.-i need more", + "^%s*could you provide more context", + "^%s*could you please provide more", + "^%s*can you clarify", + "^%s*please provide more context", + "^%s*more information needed", + "^%s*not enough context", + "^%s*i don't have enough", + "^%s*unclear what you", + "^%s*what do you mean by", + } + + local function needs_more_context(response) + if not response then + return false + end + + -- If response has substantial code, don't ask for context + local lines = vim.split(response, "\n") + local code_lines = 0 + for _, line in ipairs(lines) do + 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 code_lines >= 3 then + return false + end + + local lower = response:lower() + for _, pattern in ipairs(context_needed_patterns) do + if lower:match(pattern) then + return true + end + end + return false + end + + it("should detect context needed phrases at start", function() + assert.is_true(needs_more_context("I need more context to help you")) + assert.is_true(needs_more_context("Could you provide more context?")) + assert.is_true(needs_more_context("Can you clarify what you want?")) + assert.is_true(needs_more_context("I'm sorry, but I need more information to help")) + end) + + it("should not trigger on normal responses", function() + assert.is_false(needs_more_context("Here is your code")) + assert.is_false(needs_more_context("function test() end")) + assert.is_false(needs_more_context("The implementation is complete")) + end) + + it("should not trigger when response has substantial code", function() + local response_with_code = [[Here is the code: +function test() { + return true; +} +function another() { + return false; +}]] + assert.is_false(needs_more_context(response_with_code)) + end) + + it("should not trigger on code with explanatory text", function() + local response = [[public void test() { + System.out.println("Hello"); +} +Please replace the connection string with your actual database.]] + assert.is_false(needs_more_context(response)) + end) + + it("should handle nil response", function() + assert.is_false(needs_more_context(nil)) + end) + end) +end)