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,