fixing the issues on the tags

This commit is contained in:
2026-01-13 23:16:27 -05:00
parent fbd88993e7
commit 0600144768
16 changed files with 1647 additions and 76 deletions

View File

@@ -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 <leader>s
vim.keymap.set("n", "<C-CR>", submit, opts)
vim.keymap.set("i", "<C-CR>", submit, opts)
vim.keymap.set("n", "<leader>s", submit, opts)
vim.keymap.set("n", "<CR><CR>", submit, opts)
-- Close with Esc or q
vim.keymap.set("n", "<Esc>", M.close, opts)
vim.keymap.set("n", "q", M.close, opts)
-- Start in insert mode
vim.cmd("startinsert")
-- Log
pcall(function()
local logs = require("codetyper.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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <C-x><C-f> style keybinding in insert mode
vim.keymap.set("i", "<C-x>@", function()
M.show_file_completion()
end, { silent = true, desc = "Coder: Complete file reference" })
end
return M

View File

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

View File

@@ -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", "<Esc>", M.close, opts)
-- Setup keymaps for queue buffer
local queue_opts = { buffer = state.queue_buf, noremap = true, silent = true }
vim.keymap.set("n", "q", M.close, queue_opts)
vim.keymap.set("n", "<Esc>", M.close, queue_opts)
-- Register log listener
state.listener_id = logs.add_listener(function(entry)
add_log_entry(entry)
@@ -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

View File

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

View File

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

View File

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

View File

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

269
tests/spec/worker_spec.lua Normal file
View File

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