Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5493a5ec38 | |||
| c3da2901c9 | |||
| 46672f6f87 | |||
| 0600144768 |
66
README.md
66
README.md
@@ -46,6 +46,16 @@
|
||||
- curl (for API calls)
|
||||
- One of: Claude API key, OpenAI API key, Gemini API key, GitHub Copilot, or Ollama running locally
|
||||
|
||||
### Required Plugins
|
||||
|
||||
- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) - Async utilities
|
||||
- [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) - Scope detection for functions/methods
|
||||
|
||||
### Optional Plugins
|
||||
|
||||
- [nvim-treesitter-textobjects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects) - Better text object support
|
||||
- [nui.nvim](https://github.com/MunifTanjim/nui.nvim) - UI components
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
@@ -55,6 +65,12 @@
|
||||
```lua
|
||||
{
|
||||
"cargdev/codetyper.nvim",
|
||||
dependencies = {
|
||||
"nvim-lua/plenary.nvim", -- Required: async utilities
|
||||
"nvim-treesitter/nvim-treesitter", -- Required: scope detection
|
||||
"nvim-treesitter/nvim-treesitter-textobjects", -- Optional: text objects
|
||||
"MunifTanjim/nui.nvim", -- Optional: UI components
|
||||
},
|
||||
cmd = { "Coder", "CoderOpen", "CoderToggle", "CoderAgent" },
|
||||
keys = {
|
||||
{ "<leader>co", "<cmd>Coder open<cr>", desc = "Coder: Open" },
|
||||
@@ -167,6 +183,7 @@ require("codetyper").setup({
|
||||
escalation_threshold = 0.7, -- Below this confidence, escalate to remote
|
||||
max_concurrent = 2, -- Max parallel workers
|
||||
completion_delay_ms = 100, -- Delay injection after completion popup
|
||||
apply_delay_ms = 5000, -- Wait before applying code (ms), allows review
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -317,11 +334,54 @@ The plugin auto-detects prompt type:
|
||||
|
||||
| Keywords | Type | Behavior |
|
||||
|----------|------|----------|
|
||||
| `refactor`, `rewrite` | Refactor | Replaces code |
|
||||
| `add`, `create`, `implement` | Add | Inserts new code |
|
||||
| `document`, `comment` | Document | Adds documentation |
|
||||
| `complete`, `finish`, `implement`, `todo` | Complete | Completes function body (replaces scope) |
|
||||
| `refactor`, `rewrite`, `simplify` | Refactor | Replaces code |
|
||||
| `fix`, `debug`, `bug`, `error` | Fix | Fixes bugs (replaces scope) |
|
||||
| `add`, `create`, `generate` | Add | Inserts new code |
|
||||
| `document`, `comment`, `jsdoc` | Document | Adds documentation |
|
||||
| `optimize`, `performance`, `faster` | Optimize | Optimizes code (replaces scope) |
|
||||
| `explain`, `what`, `how` | Explain | Shows explanation only |
|
||||
|
||||
### Function Completion
|
||||
|
||||
When you write a prompt **inside** a function body, the plugin uses Tree-sitter to detect the enclosing scope and automatically switches to "complete" mode:
|
||||
|
||||
```typescript
|
||||
function getUserById(id: number): User | null {
|
||||
/@ return the user from the database by id, handle not found case @/
|
||||
}
|
||||
```
|
||||
|
||||
The LLM will complete the function body while keeping the exact same signature. The entire function scope is replaced with the completed version.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Logs Panel
|
||||
|
||||
The logs panel provides real-time visibility into LLM operations:
|
||||
|
||||
### Features
|
||||
|
||||
- **Generation Logs**: Shows all LLM requests, responses, and token usage
|
||||
- **Queue Display**: Shows pending and processing prompts
|
||||
- **Full Response View**: Complete LLM responses are logged for debugging
|
||||
- **Auto-cleanup**: Logs panel and queue windows automatically close when exiting Neovim
|
||||
|
||||
### Opening the Logs Panel
|
||||
|
||||
```vim
|
||||
:CoderLogs
|
||||
```
|
||||
|
||||
The logs panel opens automatically when processing prompts with the scheduler enabled.
|
||||
|
||||
### Keymaps
|
||||
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| `q` | Close logs panel |
|
||||
| `<Esc>` | Close logs panel |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Agent Mode
|
||||
|
||||
177
lua/codetyper/agent/context_modal.lua
Normal file
177
lua/codetyper/agent/context_modal.lua
Normal file
@@ -0,0 +1,177 @@
|
||||
---@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
|
||||
|
||||
--- Setup autocmds for the context modal
|
||||
function M.setup()
|
||||
local group = vim.api.nvim_create_augroup("CodetypeContextModal", { clear = true })
|
||||
|
||||
-- Close context modal when exiting Neovim
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
M.close()
|
||||
end,
|
||||
desc = "Close context modal before exiting Neovim",
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -230,9 +230,22 @@ function M.format_entry(entry)
|
||||
response = "<",
|
||||
tool = "T",
|
||||
error = "!",
|
||||
warning = "?",
|
||||
success = "i",
|
||||
queue = "Q",
|
||||
patch = "P",
|
||||
})[entry.level] or "?"
|
||||
|
||||
return string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message)
|
||||
local base = string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message)
|
||||
|
||||
-- If this is a response entry with raw_response, append the full response
|
||||
if entry.data and entry.data.raw_response then
|
||||
local response = entry.data.raw_response
|
||||
-- Add separator and the full response
|
||||
base = base .. "\n" .. string.rep("-", 40) .. "\n" .. response .. "\n" .. string.rep("-", 40)
|
||||
end
|
||||
|
||||
return base
|
||||
end
|
||||
|
||||
--- Estimate token count for a string (rough approximation)
|
||||
|
||||
@@ -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,11 +314,113 @@ 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
|
||||
|
||||
--- Check if it's safe to modify the buffer (not in insert mode)
|
||||
---@return boolean
|
||||
local function is_safe_to_modify()
|
||||
local mode = vim.fn.mode()
|
||||
-- Don't modify if in insert mode or completion is visible
|
||||
if mode == "i" or mode == "ic" or mode == "ix" then
|
||||
return false
|
||||
end
|
||||
if vim.fn.pumvisible() == 1 then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- Apply a patch to the target buffer
|
||||
---@param patch PatchCandidate
|
||||
---@return boolean success
|
||||
---@return string|nil error
|
||||
function M.apply(patch)
|
||||
-- Check if safe to modify (not in insert mode)
|
||||
if not is_safe_to_modify() then
|
||||
return false, "user_typing"
|
||||
end
|
||||
|
||||
-- Check staleness first
|
||||
local is_stale, stale_reason = M.is_stale(patch)
|
||||
if is_stale then
|
||||
@@ -349,29 +453,107 @@ 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
|
||||
)
|
||||
-- Replace the scope range with the new code
|
||||
-- The injection_range points to the function/method we're completing
|
||||
local start_line = patch.injection_range.start_line
|
||||
local end_line = patch.injection_range.end_line
|
||||
|
||||
-- Adjust for tag removal - find the new range by searching for the scope
|
||||
-- After removing tags, line numbers may have shifted
|
||||
-- Use the scope information to find the correct range
|
||||
if patch.scope and patch.scope.type then
|
||||
-- Try to find the scope using treesitter if available
|
||||
local found_range = nil
|
||||
pcall(function()
|
||||
local ts_utils = require("nvim-treesitter.ts_utils")
|
||||
local parsers = require("nvim-treesitter.parsers")
|
||||
local parser = parsers.get_parser(target_bufnr)
|
||||
if parser then
|
||||
local tree = parser:parse()[1]
|
||||
if tree then
|
||||
local root = tree:root()
|
||||
-- Find the function/method node that contains our original position
|
||||
local function find_scope_node(node)
|
||||
local node_type = node:type()
|
||||
local is_scope = node_type:match("function")
|
||||
or node_type:match("method")
|
||||
or node_type:match("class")
|
||||
or node_type:match("declaration")
|
||||
|
||||
if is_scope then
|
||||
local s_row, _, e_row, _ = node:range()
|
||||
-- Check if this scope roughly matches our expected range
|
||||
if math.abs(s_row - (start_line - 1)) <= 5 then
|
||||
found_range = { start_line = s_row + 1, end_line = e_row + 1 }
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
for child in node:iter_children() do
|
||||
if find_scope_node(child) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
find_scope_node(root)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
if found_range then
|
||||
start_line = found_range.start_line
|
||||
end_line = found_range.end_line
|
||||
end
|
||||
end
|
||||
|
||||
-- Clamp to valid range
|
||||
start_line = math.max(1, start_line)
|
||||
end_line = math.min(line_count, end_line)
|
||||
|
||||
-- Replace the range (0-indexed for nvim_buf_set_lines)
|
||||
vim.api.nvim_buf_set_lines(target_bufnr, start_line - 1, end_line, false, code_lines)
|
||||
|
||||
pcall(function()
|
||||
local logs = require("codetyper.agent.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format("Replacing lines %d-%d with %d lines of code", start_line, end_line, #code_lines),
|
||||
})
|
||||
end)
|
||||
elseif patch.injection_strategy == "insert" and patch.injection_range then
|
||||
-- Insert at 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 the specified location
|
||||
local insert_line = patch.injection_range.start_line
|
||||
insert_line = math.max(1, math.min(line_count + 1, insert_line))
|
||||
vim.api.nvim_buf_set_lines(target_bufnr, insert_line - 1, insert_line - 1, false, code_lines)
|
||||
else
|
||||
-- Default: append to end
|
||||
local line_count = vim.api.nvim_buf_line_count(target_bufnr)
|
||||
-- Check if last line is empty, if not add a blank line for spacing
|
||||
local last_line = vim.api.nvim_buf_get_lines(target_bufnr, line_count - 1, line_count, false)[1] or ""
|
||||
if last_line:match("%S") then
|
||||
-- Last line has content, add blank line for spacing
|
||||
table.insert(code_lines, 1, "")
|
||||
end
|
||||
vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines)
|
||||
end
|
||||
end)
|
||||
@@ -401,22 +583,27 @@ end
|
||||
--- Flush all pending patches that are safe to apply
|
||||
---@return number applied_count
|
||||
---@return number stale_count
|
||||
---@return number deferred_count
|
||||
function M.flush_pending()
|
||||
local applied = 0
|
||||
local stale = 0
|
||||
local deferred = 0
|
||||
|
||||
for _, patch in ipairs(patches) do
|
||||
if patch.status == "pending" then
|
||||
local success, _ = M.apply(patch)
|
||||
for _, p in ipairs(patches) do
|
||||
if p.status == "pending" then
|
||||
local success, err = M.apply(p)
|
||||
if success then
|
||||
applied = applied + 1
|
||||
elseif err == "user_typing" then
|
||||
-- Keep pending, will retry later
|
||||
deferred = deferred + 1
|
||||
else
|
||||
stale = stale + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return applied, stale
|
||||
return applied, stale, deferred
|
||||
end
|
||||
|
||||
--- Cancel all pending patches for a buffer
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,10 @@ 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")
|
||||
|
||||
-- Setup context modal cleanup on exit
|
||||
context_modal.setup()
|
||||
|
||||
--- Scheduler state
|
||||
local state = {
|
||||
@@ -23,6 +27,7 @@ local state = {
|
||||
escalation_threshold = 0.7,
|
||||
max_concurrent = 2,
|
||||
completion_delay_ms = 100,
|
||||
apply_delay_ms = 5000, -- Wait before applying code
|
||||
remote_provider = "claude", -- Default fallback provider
|
||||
},
|
||||
}
|
||||
@@ -118,10 +123,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
|
||||
@@ -178,8 +232,19 @@ local function handle_worker_result(event, result)
|
||||
|
||||
queue.complete(event.id)
|
||||
|
||||
-- Schedule patch application
|
||||
M.schedule_patch_flush()
|
||||
-- Schedule patch application after delay (gives user time to review/cancel)
|
||||
local delay = state.config.apply_delay_ms or 5000
|
||||
pcall(function()
|
||||
local logs = require("codetyper.agent.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = string.format("Code ready. Applying in %.1f seconds...", delay / 1000),
|
||||
})
|
||||
end)
|
||||
|
||||
vim.defer_fn(function()
|
||||
M.schedule_patch_flush()
|
||||
end, delay)
|
||||
end
|
||||
|
||||
--- Dispatch next event from queue
|
||||
@@ -241,11 +306,23 @@ local function dispatch_next()
|
||||
end)
|
||||
end
|
||||
|
||||
--- Track if we're already waiting to flush (avoid spam logs)
|
||||
local waiting_to_flush = false
|
||||
|
||||
--- Schedule patch flush after delay (completion safety)
|
||||
--- Will keep retrying until safe to inject or no pending patches
|
||||
function M.schedule_patch_flush()
|
||||
vim.defer_fn(function()
|
||||
-- Check if there are any pending patches
|
||||
local pending = patch.get_pending()
|
||||
if #pending == 0 then
|
||||
waiting_to_flush = false
|
||||
return -- Nothing to apply
|
||||
end
|
||||
|
||||
local safe, reason = M.is_safe_to_inject()
|
||||
if safe then
|
||||
waiting_to_flush = false
|
||||
local applied, stale = patch.flush_pending()
|
||||
if applied > 0 or stale > 0 then
|
||||
pcall(function()
|
||||
@@ -257,15 +334,20 @@ function M.schedule_patch_flush()
|
||||
end)
|
||||
end
|
||||
else
|
||||
-- Not safe yet, reschedule
|
||||
pcall(function()
|
||||
local logs = require("codetyper.agent.logs")
|
||||
logs.add({
|
||||
type = "debug",
|
||||
message = string.format("Patch flush deferred: %s", reason or "unknown"),
|
||||
})
|
||||
end)
|
||||
-- Will be retried on next InsertLeave or CursorHold
|
||||
-- Not safe yet (user is typing), reschedule to try again
|
||||
-- Only log once when we start waiting
|
||||
if not waiting_to_flush then
|
||||
waiting_to_flush = true
|
||||
pcall(function()
|
||||
local logs = require("codetyper.agent.logs")
|
||||
logs.add({
|
||||
type = "info",
|
||||
message = "Waiting for user to finish typing before applying code...",
|
||||
})
|
||||
end)
|
||||
end
|
||||
-- Retry after a delay - keep waiting for user to finish typing
|
||||
M.schedule_patch_flush()
|
||||
end
|
||||
end, state.config.completion_delay_ms)
|
||||
end
|
||||
@@ -340,6 +422,15 @@ local function setup_autocmds()
|
||||
end,
|
||||
desc = "Cleanup on buffer delete",
|
||||
})
|
||||
|
||||
-- Stop scheduler when exiting Neovim
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = augroup,
|
||||
callback = function()
|
||||
M.stop()
|
||||
end,
|
||||
desc = "Stop scheduler before exiting Neovim",
|
||||
})
|
||||
end
|
||||
|
||||
--- Start the scheduler
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,146 @@ 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 LLM special tokens (deepseek, llama, etc.)
|
||||
cleaned = cleaned:gsub("<|begin▁of▁sentence|>", "")
|
||||
cleaned = cleaned:gsub("<|end▁of▁sentence|>", "")
|
||||
cleaned = cleaned:gsub("<|im_start|>", "")
|
||||
cleaned = cleaned:gsub("<|im_end|>", "")
|
||||
cleaned = cleaned:gsub("<s>", "")
|
||||
cleaned = cleaned:gsub("</s>", "")
|
||||
cleaned = cleaned:gsub("<|endoftext|>", "")
|
||||
|
||||
-- Remove the original prompt tags /@ ... @/ if they appear in output
|
||||
-- Use [%s%S] to match any character including newlines (Lua's . doesn't match newlines)
|
||||
cleaned = cleaned:gsub("/@[%s%S]-@/", "")
|
||||
|
||||
-- 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 +203,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 +245,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 +257,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
|
||||
@@ -107,15 +273,42 @@ local function build_prompt(event)
|
||||
local scope_type = event.scope.type
|
||||
local scope_name = event.scope.name or "anonymous"
|
||||
|
||||
-- For replacement intents, provide the full scope to transform
|
||||
if event.intent and intent_mod.is_replacement(event.intent) then
|
||||
-- Special handling for "complete" intent - fill in the function body
|
||||
if event.intent and event.intent.type == "complete" then
|
||||
user_prompt = string.format(
|
||||
[[Complete this %s. Fill in the implementation based on the description.
|
||||
|
||||
IMPORTANT:
|
||||
- Keep the EXACT same function signature (name, parameters, return type)
|
||||
- Only provide the COMPLETE function with implementation
|
||||
- Do NOT create a new function or duplicate the signature
|
||||
- Do NOT add any text before or after the function
|
||||
|
||||
Current %s (incomplete):
|
||||
```%s
|
||||
%s
|
||||
```
|
||||
%s
|
||||
What it should do: %s
|
||||
|
||||
Return ONLY the complete %s with implementation. No explanations, no duplicates.]],
|
||||
scope_type,
|
||||
scope_type,
|
||||
filetype,
|
||||
event.scope_text,
|
||||
attached_content,
|
||||
event.prompt_content,
|
||||
scope_type
|
||||
)
|
||||
-- For other replacement intents, provide the full scope to transform
|
||||
elseif event.intent and intent_mod.is_replacement(event.intent) then
|
||||
user_prompt = string.format(
|
||||
[[Here is a %s named "%s" in a %s file:
|
||||
|
||||
```%s
|
||||
%s
|
||||
```
|
||||
|
||||
%s
|
||||
User request: %s
|
||||
|
||||
Return the complete transformed %s. Output only code, no explanations.]],
|
||||
@@ -124,6 +317,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 +329,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 +337,7 @@ Output only the code to insert, no explanations.]],
|
||||
scope_name,
|
||||
filetype,
|
||||
event.scope_text,
|
||||
attached_content,
|
||||
event.prompt_content
|
||||
)
|
||||
end
|
||||
@@ -154,7 +349,7 @@ Output only the code to insert, no explanations.]],
|
||||
```%s
|
||||
%s
|
||||
```
|
||||
|
||||
%s
|
||||
User request: %s
|
||||
|
||||
Output only code, no explanations.]],
|
||||
@@ -162,6 +357,7 @@ Output only code, no explanations.]],
|
||||
filetype,
|
||||
filetype,
|
||||
target_content:sub(1, 4000), -- Limit context size
|
||||
attached_content,
|
||||
event.prompt_content
|
||||
)
|
||||
end
|
||||
@@ -303,8 +499,52 @@ 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
|
||||
|
||||
-- Log the full raw LLM response (for debugging)
|
||||
pcall(function()
|
||||
local logs = require("codetyper.agent.logs")
|
||||
logs.add({
|
||||
type = "response",
|
||||
message = "--- LLM Response ---",
|
||||
data = {
|
||||
raw_response = response,
|
||||
},
|
||||
})
|
||||
end)
|
||||
|
||||
-- Clean the response (remove markdown, explanations, etc.)
|
||||
local filetype = vim.fn.fnamemodify(worker.event.target_path or "", ":e")
|
||||
local cleaned_response = clean_response(response, filetype)
|
||||
|
||||
-- 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 +566,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,
|
||||
|
||||
@@ -15,6 +15,9 @@ local TREE_UPDATE_DEBOUNCE_MS = 1000 -- 1 second debounce
|
||||
---@type table<string, boolean>
|
||||
local processed_prompts = {}
|
||||
|
||||
--- Track if we're currently asking for preferences
|
||||
local asking_preference = false
|
||||
|
||||
--- Generate a unique key for a prompt
|
||||
---@param bufnr number Buffer number
|
||||
---@param prompt table Prompt object
|
||||
@@ -40,19 +43,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()
|
||||
-- Check for closed prompts and auto-process (respects preferences)
|
||||
M.check_for_closed_prompt_with_preference()
|
||||
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_with_preference()
|
||||
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_with_preference()
|
||||
end
|
||||
end,
|
||||
desc = "Auto-process closed prompts when idle in normal mode",
|
||||
})
|
||||
|
||||
-- Auto-set filetype for coder files based on extension
|
||||
@@ -172,12 +217,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 +310,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,31 +321,53 @@ 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)
|
||||
|
||||
-- Detect intent from prompt
|
||||
local intent = intent_mod.detect(cleaned)
|
||||
-- Clean prompt content (strip file references)
|
||||
local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content))
|
||||
|
||||
-- Resolve scope in target file (use prompt position to find enclosing scope)
|
||||
-- Resolve scope in target file FIRST (need it to adjust intent)
|
||||
local target_bufnr = vim.fn.bufnr(target_path)
|
||||
if target_bufnr == -1 then
|
||||
target_bufnr = bufnr
|
||||
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,
|
||||
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
|
||||
|
||||
-- Detect intent from prompt
|
||||
local intent = intent_mod.detect(cleaned)
|
||||
|
||||
-- IMPORTANT: If prompt is inside a function/method and intent is "add",
|
||||
-- override to "complete" since we're completing the function body
|
||||
if scope and (scope.type == "function" or scope.type == "method") then
|
||||
if intent.type == "add" or intent.action == "insert" or intent.action == "append" then
|
||||
-- Override to complete the function instead of adding new code
|
||||
intent = {
|
||||
type = "complete",
|
||||
scope_hint = "function",
|
||||
confidence = intent.confidence,
|
||||
action = "replace",
|
||||
keywords = intent.keywords,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -279,6 +397,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 +419,251 @@ 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))
|
||||
|
||||
-- Resolve scope in target file FIRST (need it to adjust intent)
|
||||
local target_bufnr = vim.fn.bufnr(target_path)
|
||||
if target_bufnr == -1 then
|
||||
target_bufnr = bufnr -- Use current buffer if target not loaded
|
||||
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
|
||||
|
||||
-- Detect intent from prompt
|
||||
local intent = intent_mod.detect(cleaned)
|
||||
|
||||
-- IMPORTANT: If prompt is inside a function/method and intent is "add",
|
||||
-- override to "complete" since we're completing the function body
|
||||
if scope and (scope.type == "function" or scope.type == "method") then
|
||||
if intent.type == "add" or intent.action == "insert" or intent.action == "append" then
|
||||
-- Override to complete the function instead of adding new code
|
||||
intent = {
|
||||
type = "complete",
|
||||
scope_hint = "function",
|
||||
confidence = intent.confidence,
|
||||
action = "replace",
|
||||
keywords = intent.keywords,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Determine priority based on intent
|
||||
local priority = 2
|
||||
if intent.type == "fix" or intent.type == "complete" then
|
||||
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
|
||||
|
||||
--- Check for closed prompt with preference check
|
||||
--- If user hasn't chosen auto/manual mode, ask them first
|
||||
function M.check_for_closed_prompt_with_preference()
|
||||
local preferences = require("codetyper.preferences")
|
||||
local parser = require("codetyper.parser")
|
||||
|
||||
-- First check if there are any prompts to process
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local prompts = parser.find_prompts_in_buffer(bufnr)
|
||||
if #prompts == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check user preference
|
||||
local auto_process = preferences.is_auto_process_enabled()
|
||||
|
||||
if auto_process == nil then
|
||||
-- Not yet decided - ask the user (but only once per session)
|
||||
if not asking_preference then
|
||||
asking_preference = true
|
||||
preferences.ask_auto_process_preference(function(enabled)
|
||||
asking_preference = false
|
||||
if enabled then
|
||||
-- User chose automatic - process now
|
||||
M.check_for_closed_prompt()
|
||||
else
|
||||
-- User chose manual - show hint
|
||||
utils.notify("Use :CoderProcess to process prompt tags manually", vim.log.levels.INFO)
|
||||
end
|
||||
end)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if auto_process then
|
||||
-- Automatic mode - process prompts
|
||||
M.check_for_closed_prompt()
|
||||
end
|
||||
-- Manual mode - do nothing, user will run :CoderProcess
|
||||
end
|
||||
|
||||
--- Check all prompts with preference check
|
||||
function M.check_all_prompts_with_preference()
|
||||
local preferences = require("codetyper.preferences")
|
||||
local parser = require("codetyper.parser")
|
||||
|
||||
-- First check if there are any prompts to process
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local prompts = parser.find_prompts_in_buffer(bufnr)
|
||||
if #prompts == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check if any prompts are unprocessed
|
||||
local has_unprocessed = false
|
||||
for _, prompt in ipairs(prompts) do
|
||||
local prompt_key = get_prompt_key(bufnr, prompt)
|
||||
if not processed_prompts[prompt_key] then
|
||||
has_unprocessed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not has_unprocessed then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check user preference
|
||||
local auto_process = preferences.is_auto_process_enabled()
|
||||
|
||||
if auto_process == nil then
|
||||
-- Not yet decided - ask the user (but only once per session)
|
||||
if not asking_preference then
|
||||
asking_preference = true
|
||||
preferences.ask_auto_process_preference(function(enabled)
|
||||
asking_preference = false
|
||||
if enabled then
|
||||
-- User chose automatic - process now
|
||||
M.check_all_prompts()
|
||||
else
|
||||
-- User chose manual - show hint
|
||||
utils.notify("Use :CoderProcess to process prompt tags manually", vim.log.levels.INFO)
|
||||
end
|
||||
end)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if auto_process then
|
||||
-- Automatic mode - process prompts
|
||||
M.check_all_prompts()
|
||||
end
|
||||
-- Manual mode - do nothing, user will run :CoderProcess
|
||||
end
|
||||
|
||||
--- Reset processed prompts for a buffer (useful for re-processing)
|
||||
---@param bufnr? number Buffer number (default: current)
|
||||
function M.reset_processed(bufnr)
|
||||
|
||||
@@ -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,31 @@ 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,
|
||||
["auto-toggle"] = function()
|
||||
local preferences = require("codetyper.preferences")
|
||||
preferences.toggle_auto_process()
|
||||
end,
|
||||
["auto-set"] = function(args)
|
||||
local preferences = require("codetyper.preferences")
|
||||
local arg = (args[1] or ""):lower()
|
||||
if arg == "auto" or arg == "automatic" or arg == "on" then
|
||||
preferences.set_auto_process(true)
|
||||
utils.notify("Set to automatic mode", vim.log.levels.INFO)
|
||||
elseif arg == "manual" or arg == "off" then
|
||||
preferences.set_auto_process(false)
|
||||
utils.notify("Set to manual mode", vim.log.levels.INFO)
|
||||
else
|
||||
local auto = preferences.is_auto_process_enabled()
|
||||
if auto == nil then
|
||||
utils.notify("Mode not set yet (will ask on first prompt)", vim.log.levels.INFO)
|
||||
else
|
||||
local mode = auto and "automatic" or "manual"
|
||||
utils.notify("Currently in " .. mode .. " mode", vim.log.levels.INFO)
|
||||
end
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
local cmd_fn = commands[subcommand]
|
||||
@@ -707,6 +786,8 @@ function M.setup()
|
||||
"transform", "transform-cursor",
|
||||
"agent", "agent-close", "agent-toggle", "agent-stop",
|
||||
"type-toggle", "logs-toggle",
|
||||
"queue-status", "queue-process",
|
||||
"auto-toggle", "auto-set",
|
||||
}
|
||||
end,
|
||||
desc = "Codetyper.nvim commands",
|
||||
@@ -794,6 +875,48 @@ 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" })
|
||||
|
||||
-- Preferences commands
|
||||
vim.api.nvim_create_user_command("CoderAutoToggle", function()
|
||||
local preferences = require("codetyper.preferences")
|
||||
preferences.toggle_auto_process()
|
||||
end, { desc = "Toggle automatic/manual prompt processing" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderAutoSet", function(opts)
|
||||
local preferences = require("codetyper.preferences")
|
||||
local arg = opts.args:lower()
|
||||
if arg == "auto" or arg == "automatic" or arg == "on" then
|
||||
preferences.set_auto_process(true)
|
||||
vim.notify("Codetyper: Set to automatic mode", vim.log.levels.INFO)
|
||||
elseif arg == "manual" or arg == "off" then
|
||||
preferences.set_auto_process(false)
|
||||
vim.notify("Codetyper: Set to manual mode", vim.log.levels.INFO)
|
||||
else
|
||||
-- Show current mode
|
||||
local auto = preferences.is_auto_process_enabled()
|
||||
if auto == nil then
|
||||
vim.notify("Codetyper: Mode not set yet (will ask on first prompt)", vim.log.levels.INFO)
|
||||
else
|
||||
local mode = auto and "automatic" or "manual"
|
||||
vim.notify("Codetyper: Currently in " .. mode .. " mode", vim.log.levels.INFO)
|
||||
end
|
||||
end
|
||||
end, {
|
||||
desc = "Set prompt processing mode (auto/manual)",
|
||||
nargs = "?",
|
||||
complete = function()
|
||||
return { "auto", "manual" }
|
||||
end,
|
||||
})
|
||||
|
||||
-- Setup default keymaps
|
||||
M.setup_keymaps()
|
||||
end
|
||||
|
||||
192
lua/codetyper/completion.lua
Normal file
192
lua/codetyper/completion.lua
Normal file
@@ -0,0 +1,192 @@
|
||||
---@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 current_file = vim.fn.expand("%:p")
|
||||
local current_dir = vim.fn.fnamemodify(current_file, ":h")
|
||||
local files = {}
|
||||
|
||||
-- Use vim.fn.glob to find files matching the prefix
|
||||
local pattern = prefix .. "*"
|
||||
|
||||
-- Determine base directory - use current file's directory if outside cwd
|
||||
local base_dir = cwd
|
||||
if current_dir ~= "" and not current_dir:find(cwd, 1, true) then
|
||||
-- File is outside project, use its directory as base
|
||||
base_dir = current_dir
|
||||
end
|
||||
|
||||
-- Search in base directory
|
||||
local matches = vim.fn.glob(base_dir .. "/" .. pattern, false, true)
|
||||
|
||||
-- Search with ** for all subdirectories
|
||||
local deep_matches = vim.fn.glob(base_dir .. "/**/" .. pattern, false, true)
|
||||
for _, m in ipairs(deep_matches) do
|
||||
table.insert(matches, m)
|
||||
end
|
||||
|
||||
-- Also search in cwd if different from base_dir
|
||||
if base_dir ~= cwd then
|
||||
local cwd_matches = vim.fn.glob(cwd .. "/" .. pattern, false, true)
|
||||
for _, m in ipairs(cwd_matches) do
|
||||
table.insert(matches, m)
|
||||
end
|
||||
local cwd_deep = vim.fn.glob(cwd .. "/**/" .. pattern, false, true)
|
||||
for _, m in ipairs(cwd_deep) do
|
||||
table.insert(matches, m)
|
||||
end
|
||||
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 = base_dir .. "/" .. 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
|
||||
-- Convert to relative path based on which base it came from
|
||||
local rel_path
|
||||
if match:find(base_dir, 1, true) == 1 then
|
||||
rel_path = match:sub(#base_dir + 2)
|
||||
elseif match:find(cwd, 1, true) == 1 then
|
||||
rel_path = match:sub(#cwd + 2)
|
||||
else
|
||||
rel_path = vim.fn.fnamemodify(match, ":t") -- Just filename if can't make relative
|
||||
end
|
||||
|
||||
-- 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
|
||||
@@ -46,6 +46,7 @@ local defaults = {
|
||||
escalation_threshold = 0.7, -- Below this confidence, escalate to remote LLM
|
||||
max_concurrent = 2, -- Maximum concurrent workers
|
||||
completion_delay_ms = 100, -- Wait after completion popup closes
|
||||
apply_delay_ms = 5000, -- Wait before removing tags and applying code (ms)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ function M.setup(opts)
|
||||
local gitignore = require("codetyper.gitignore")
|
||||
local autocmds = require("codetyper.autocmds")
|
||||
local tree = require("codetyper.tree")
|
||||
local completion = require("codetyper.completion")
|
||||
local logs_panel = require("codetyper.logs_panel")
|
||||
|
||||
-- Register commands
|
||||
commands.setup()
|
||||
@@ -37,6 +39,12 @@ function M.setup(opts)
|
||||
-- Setup autocommands
|
||||
autocmds.setup()
|
||||
|
||||
-- Setup file reference completion
|
||||
completion.setup()
|
||||
|
||||
-- Setup logs panel (handles VimLeavePre cleanup)
|
||||
logs_panel.setup()
|
||||
|
||||
-- Ensure .gitignore has coder files excluded
|
||||
gitignore.ensure_ignored()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -158,25 +274,48 @@ function M.open()
|
||||
end
|
||||
|
||||
--- Close the logs panel
|
||||
function M.close()
|
||||
if not state.is_open then
|
||||
---@param force? boolean Force close even if not marked as open
|
||||
function M.close(force)
|
||||
if not state.is_open and not force then
|
||||
return
|
||||
end
|
||||
|
||||
-- Remove log listener
|
||||
if state.listener_id then
|
||||
logs.remove_listener(state.listener_id)
|
||||
pcall(logs.remove_listener, state.listener_id)
|
||||
state.listener_id = nil
|
||||
end
|
||||
|
||||
-- Close window
|
||||
if state.win and vim.api.nvim_win_is_valid(state.win) then
|
||||
pcall(vim.api.nvim_win_close, state.win, true)
|
||||
-- Remove queue listener
|
||||
if state.queue_listener_id then
|
||||
pcall(queue.remove_listener, state.queue_listener_id)
|
||||
state.queue_listener_id = nil
|
||||
end
|
||||
|
||||
-- Close queue window first
|
||||
if state.queue_win then
|
||||
pcall(vim.api.nvim_win_close, state.queue_win, true)
|
||||
state.queue_win = nil
|
||||
end
|
||||
|
||||
-- Close logs window
|
||||
if state.win then
|
||||
pcall(vim.api.nvim_win_close, state.win, true)
|
||||
state.win = nil
|
||||
end
|
||||
|
||||
-- Delete queue buffer
|
||||
if state.queue_buf then
|
||||
pcall(vim.api.nvim_buf_delete, state.queue_buf, { force = true })
|
||||
state.queue_buf = nil
|
||||
end
|
||||
|
||||
-- Delete logs buffer
|
||||
if state.buf then
|
||||
pcall(vim.api.nvim_buf_delete, state.buf, { force = true })
|
||||
state.buf = nil
|
||||
end
|
||||
|
||||
-- Reset state
|
||||
state.buf = nil
|
||||
state.win = nil
|
||||
state.is_open = false
|
||||
end
|
||||
|
||||
@@ -202,4 +341,42 @@ function M.ensure_open()
|
||||
end
|
||||
end
|
||||
|
||||
--- Setup autocmds for the logs panel
|
||||
function M.setup()
|
||||
local group = vim.api.nvim_create_augroup("CodetypeLogsPanel", { clear = true })
|
||||
|
||||
-- Close logs panel when exiting Neovim
|
||||
vim.api.nvim_create_autocmd("VimLeavePre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
-- Force close to ensure cleanup even in edge cases
|
||||
M.close(true)
|
||||
end,
|
||||
desc = "Close logs panel before exiting Neovim",
|
||||
})
|
||||
|
||||
-- Also clean up when QuitPre fires (handles :qa, :wqa, etc.)
|
||||
vim.api.nvim_create_autocmd("QuitPre", {
|
||||
group = group,
|
||||
callback = function()
|
||||
-- Check if this is the last window (about to quit Neovim)
|
||||
local wins = vim.api.nvim_list_wins()
|
||||
local real_wins = 0
|
||||
for _, win in ipairs(wins) do
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
local buftype = vim.bo[buf].buftype
|
||||
-- Count non-special windows
|
||||
if buftype == "" or buftype == "help" then
|
||||
real_wins = real_wins + 1
|
||||
end
|
||||
end
|
||||
-- If only logs/queue windows remain, close them
|
||||
if real_wins <= 1 then
|
||||
M.close(true)
|
||||
end
|
||||
end,
|
||||
desc = "Close logs panel on quit",
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -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
|
||||
|
||||
214
lua/codetyper/preferences.lua
Normal file
214
lua/codetyper/preferences.lua
Normal file
@@ -0,0 +1,214 @@
|
||||
---@mod codetyper.preferences User preferences management
|
||||
---@brief [[
|
||||
--- Manages user preferences stored in .coder/preferences.json
|
||||
--- Allows per-project configuration of plugin behavior.
|
||||
---@brief ]]
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.utils")
|
||||
|
||||
---@class CoderPreferences
|
||||
---@field auto_process boolean Whether to auto-process /@ @/ tags (default: nil = ask)
|
||||
---@field asked_auto_process boolean Whether we've asked the user about auto_process
|
||||
|
||||
--- Default preferences
|
||||
local defaults = {
|
||||
auto_process = nil, -- nil means "not yet decided"
|
||||
asked_auto_process = false,
|
||||
}
|
||||
|
||||
--- Cached preferences per project
|
||||
---@type table<string, CoderPreferences>
|
||||
local cache = {}
|
||||
|
||||
--- Get the preferences file path for current project
|
||||
---@return string
|
||||
local function get_preferences_path()
|
||||
local cwd = vim.fn.getcwd()
|
||||
return cwd .. "/.coder/preferences.json"
|
||||
end
|
||||
|
||||
--- Ensure .coder directory exists
|
||||
local function ensure_coder_dir()
|
||||
local cwd = vim.fn.getcwd()
|
||||
local coder_dir = cwd .. "/.coder"
|
||||
if vim.fn.isdirectory(coder_dir) == 0 then
|
||||
vim.fn.mkdir(coder_dir, "p")
|
||||
end
|
||||
end
|
||||
|
||||
--- Load preferences from file
|
||||
---@return CoderPreferences
|
||||
function M.load()
|
||||
local cwd = vim.fn.getcwd()
|
||||
|
||||
-- Check cache first
|
||||
if cache[cwd] then
|
||||
return cache[cwd]
|
||||
end
|
||||
|
||||
local path = get_preferences_path()
|
||||
local prefs = vim.deepcopy(defaults)
|
||||
|
||||
if utils.file_exists(path) then
|
||||
local content = utils.read_file(path)
|
||||
if content then
|
||||
local ok, decoded = pcall(vim.json.decode, content)
|
||||
if ok and decoded then
|
||||
-- Merge with defaults
|
||||
for k, v in pairs(decoded) do
|
||||
prefs[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Cache it
|
||||
cache[cwd] = prefs
|
||||
return prefs
|
||||
end
|
||||
|
||||
--- Save preferences to file
|
||||
---@param prefs CoderPreferences
|
||||
function M.save(prefs)
|
||||
local cwd = vim.fn.getcwd()
|
||||
ensure_coder_dir()
|
||||
|
||||
local path = get_preferences_path()
|
||||
local ok, encoded = pcall(vim.json.encode, prefs)
|
||||
if ok then
|
||||
utils.write_file(path, encoded)
|
||||
-- Update cache
|
||||
cache[cwd] = prefs
|
||||
end
|
||||
end
|
||||
|
||||
--- Get a specific preference
|
||||
---@param key string
|
||||
---@return any
|
||||
function M.get(key)
|
||||
local prefs = M.load()
|
||||
return prefs[key]
|
||||
end
|
||||
|
||||
--- Set a specific preference
|
||||
---@param key string
|
||||
---@param value any
|
||||
function M.set(key, value)
|
||||
local prefs = M.load()
|
||||
prefs[key] = value
|
||||
M.save(prefs)
|
||||
end
|
||||
|
||||
--- Check if auto-process is enabled
|
||||
---@return boolean|nil Returns true/false if set, nil if not yet decided
|
||||
function M.is_auto_process_enabled()
|
||||
return M.get("auto_process")
|
||||
end
|
||||
|
||||
--- Set auto-process preference
|
||||
---@param enabled boolean
|
||||
function M.set_auto_process(enabled)
|
||||
M.set("auto_process", enabled)
|
||||
M.set("asked_auto_process", true)
|
||||
end
|
||||
|
||||
--- Check if we've already asked the user about auto-process
|
||||
---@return boolean
|
||||
function M.has_asked_auto_process()
|
||||
return M.get("asked_auto_process") == true
|
||||
end
|
||||
|
||||
--- Ask user about auto-process preference (shows floating window)
|
||||
---@param callback function(enabled: boolean) Called with user's choice
|
||||
function M.ask_auto_process_preference(callback)
|
||||
-- Check if already asked
|
||||
if M.has_asked_auto_process() then
|
||||
local enabled = M.is_auto_process_enabled()
|
||||
if enabled ~= nil then
|
||||
callback(enabled)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Create floating window to ask
|
||||
local width = 60
|
||||
local height = 7
|
||||
local row = math.floor((vim.o.lines - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[buf].buftype = "nofile"
|
||||
vim.bo[buf].bufhidden = "wipe"
|
||||
|
||||
local win = vim.api.nvim_open_win(buf, true, {
|
||||
relative = "editor",
|
||||
row = row,
|
||||
col = col,
|
||||
width = width,
|
||||
height = height,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
title = " Codetyper Preferences ",
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
local lines = {
|
||||
"",
|
||||
" How would you like to process /@ @/ prompt tags?",
|
||||
"",
|
||||
" [a] Automatic - Process when you close the tag",
|
||||
" [m] Manual - Only process with :CoderProcess",
|
||||
"",
|
||||
" Press 'a' or 'm' to choose (Esc to cancel)",
|
||||
}
|
||||
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
|
||||
vim.bo[buf].modifiable = false
|
||||
|
||||
-- Highlight
|
||||
local ns = vim.api.nvim_create_namespace("codetyper_prefs")
|
||||
vim.api.nvim_buf_add_highlight(buf, ns, "Title", 1, 0, -1)
|
||||
vim.api.nvim_buf_add_highlight(buf, ns, "String", 3, 2, 5)
|
||||
vim.api.nvim_buf_add_highlight(buf, ns, "String", 4, 2, 5)
|
||||
|
||||
local function close_and_callback(enabled)
|
||||
if vim.api.nvim_win_is_valid(win) then
|
||||
vim.api.nvim_win_close(win, true)
|
||||
end
|
||||
if enabled ~= nil then
|
||||
M.set_auto_process(enabled)
|
||||
local mode = enabled and "automatic" or "manual"
|
||||
vim.notify("Codetyper: Set to " .. mode .. " mode (saved to .coder/preferences.json)", vim.log.levels.INFO)
|
||||
end
|
||||
if callback then
|
||||
callback(enabled)
|
||||
end
|
||||
end
|
||||
|
||||
-- Keymaps
|
||||
local opts = { buffer = buf, noremap = true, silent = true }
|
||||
vim.keymap.set("n", "a", function() close_and_callback(true) end, opts)
|
||||
vim.keymap.set("n", "A", function() close_and_callback(true) end, opts)
|
||||
vim.keymap.set("n", "m", function() close_and_callback(false) end, opts)
|
||||
vim.keymap.set("n", "M", function() close_and_callback(false) end, opts)
|
||||
vim.keymap.set("n", "<Esc>", function() close_and_callback(nil) end, opts)
|
||||
vim.keymap.set("n", "q", function() close_and_callback(nil) end, opts)
|
||||
end
|
||||
|
||||
--- Clear cached preferences (useful when changing projects)
|
||||
function M.clear_cache()
|
||||
cache = {}
|
||||
end
|
||||
|
||||
--- Toggle auto-process mode
|
||||
function M.toggle_auto_process()
|
||||
local current = M.is_auto_process_enabled()
|
||||
local new_value = not current
|
||||
M.set_auto_process(new_value)
|
||||
local mode = new_value and "automatic" or "manual"
|
||||
vim.notify("Codetyper: Switched to " .. mode .. " mode", vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("patch", function()
|
||||
local id2 = patch.generate_id()
|
||||
|
||||
assert.is_not.equals(id1, id2)
|
||||
assert.is_true(id1:match("^patch_"))
|
||||
assert.is_truthy(id1:match("^patch_"))
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -302,4 +302,70 @@ describe("patch", function()
|
||||
assert.equals(1, #patch.get_pending())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("create_from_event", function()
|
||||
it("should create patch with replace strategy for complete intent", function()
|
||||
local event = {
|
||||
id = "evt_123",
|
||||
target_path = "/tmp/test.lua",
|
||||
bufnr = 1,
|
||||
range = { start_line = 5, end_line = 10 },
|
||||
scope_range = { start_line = 3, end_line = 12 },
|
||||
scope = { type = "function", name = "test_fn" },
|
||||
intent = {
|
||||
type = "complete",
|
||||
action = "replace",
|
||||
confidence = 0.9,
|
||||
keywords = {},
|
||||
},
|
||||
}
|
||||
|
||||
local p = patch.create_from_event(event, "function code", 0.9)
|
||||
|
||||
assert.equals("replace", p.injection_strategy)
|
||||
assert.is_truthy(p.injection_range)
|
||||
assert.equals(3, p.injection_range.start_line)
|
||||
assert.equals(12, p.injection_range.end_line)
|
||||
end)
|
||||
|
||||
it("should create patch with append strategy for add intent", function()
|
||||
local event = {
|
||||
id = "evt_456",
|
||||
target_path = "/tmp/test.lua",
|
||||
bufnr = 1,
|
||||
range = { start_line = 5, end_line = 10 },
|
||||
intent = {
|
||||
type = "add",
|
||||
action = "append",
|
||||
confidence = 0.8,
|
||||
keywords = {},
|
||||
},
|
||||
}
|
||||
|
||||
local p = patch.create_from_event(event, "new function", 0.8)
|
||||
|
||||
assert.equals("append", p.injection_strategy)
|
||||
end)
|
||||
|
||||
it("should create patch with insert strategy for insert action", function()
|
||||
local event = {
|
||||
id = "evt_789",
|
||||
target_path = "/tmp/test.lua",
|
||||
bufnr = 1,
|
||||
range = { start_line = 5, end_line = 10 },
|
||||
intent = {
|
||||
type = "add",
|
||||
action = "insert",
|
||||
confidence = 0.8,
|
||||
keywords = {},
|
||||
},
|
||||
}
|
||||
|
||||
local p = patch.create_from_event(event, "inserted code", 0.8)
|
||||
|
||||
assert.equals("insert", p.injection_strategy)
|
||||
assert.is_truthy(p.injection_range)
|
||||
assert.equals(5, p.injection_range.start_line)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
276
tests/spec/preferences_spec.lua
Normal file
276
tests/spec/preferences_spec.lua
Normal file
@@ -0,0 +1,276 @@
|
||||
---@diagnostic disable: undefined-global
|
||||
-- Tests for lua/codetyper/preferences.lua
|
||||
-- Note: UI tests (floating window) are skipped per testing guidelines
|
||||
|
||||
describe("preferences", function()
|
||||
local preferences
|
||||
local utils
|
||||
|
||||
-- Mock cwd for testing
|
||||
local test_cwd = "/tmp/codetyper_test_prefs"
|
||||
|
||||
before_each(function()
|
||||
-- Reset modules
|
||||
package.loaded["codetyper.preferences"] = nil
|
||||
package.loaded["codetyper.utils"] = nil
|
||||
|
||||
preferences = require("codetyper.preferences")
|
||||
utils = require("codetyper.utils")
|
||||
|
||||
-- Clear cache before each test
|
||||
preferences.clear_cache()
|
||||
|
||||
-- Create test directory
|
||||
vim.fn.mkdir(test_cwd, "p")
|
||||
vim.fn.mkdir(test_cwd .. "/.coder", "p")
|
||||
|
||||
-- Mock getcwd to return test directory
|
||||
vim.fn.getcwd = function()
|
||||
return test_cwd
|
||||
end
|
||||
end)
|
||||
|
||||
after_each(function()
|
||||
-- Clean up test directory
|
||||
vim.fn.delete(test_cwd, "rf")
|
||||
end)
|
||||
|
||||
describe("load", function()
|
||||
it("should return defaults when no preferences file exists", function()
|
||||
local prefs = preferences.load()
|
||||
|
||||
assert.is_table(prefs)
|
||||
assert.is_nil(prefs.auto_process)
|
||||
assert.is_false(prefs.asked_auto_process)
|
||||
end)
|
||||
|
||||
it("should load preferences from file", function()
|
||||
-- Create preferences file
|
||||
local path = test_cwd .. "/.coder/preferences.json"
|
||||
utils.write_file(path, '{"auto_process":true,"asked_auto_process":true}')
|
||||
|
||||
local prefs = preferences.load()
|
||||
|
||||
assert.is_true(prefs.auto_process)
|
||||
assert.is_true(prefs.asked_auto_process)
|
||||
end)
|
||||
|
||||
it("should merge file preferences with defaults", function()
|
||||
-- Create partial preferences file
|
||||
local path = test_cwd .. "/.coder/preferences.json"
|
||||
utils.write_file(path, '{"auto_process":false}')
|
||||
|
||||
local prefs = preferences.load()
|
||||
|
||||
assert.is_false(prefs.auto_process)
|
||||
-- Default for asked_auto_process should be preserved
|
||||
assert.is_false(prefs.asked_auto_process)
|
||||
end)
|
||||
|
||||
it("should cache preferences", function()
|
||||
local prefs1 = preferences.load()
|
||||
prefs1.test_value = "cached"
|
||||
|
||||
-- Load again - should get cached version
|
||||
local prefs2 = preferences.load()
|
||||
|
||||
assert.equals("cached", prefs2.test_value)
|
||||
end)
|
||||
|
||||
it("should handle invalid JSON gracefully", function()
|
||||
local path = test_cwd .. "/.coder/preferences.json"
|
||||
utils.write_file(path, "not valid json {{{")
|
||||
|
||||
local prefs = preferences.load()
|
||||
|
||||
-- Should return defaults
|
||||
assert.is_table(prefs)
|
||||
assert.is_nil(prefs.auto_process)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("save", function()
|
||||
it("should save preferences to file", function()
|
||||
local prefs = {
|
||||
auto_process = true,
|
||||
asked_auto_process = true,
|
||||
}
|
||||
|
||||
preferences.save(prefs)
|
||||
|
||||
-- Verify file was created
|
||||
local path = test_cwd .. "/.coder/preferences.json"
|
||||
local content = utils.read_file(path)
|
||||
assert.is_truthy(content)
|
||||
|
||||
local decoded = vim.json.decode(content)
|
||||
assert.is_true(decoded.auto_process)
|
||||
assert.is_true(decoded.asked_auto_process)
|
||||
end)
|
||||
|
||||
it("should update cache after save", function()
|
||||
local prefs = {
|
||||
auto_process = true,
|
||||
asked_auto_process = true,
|
||||
}
|
||||
|
||||
preferences.save(prefs)
|
||||
|
||||
-- Load should return the saved values from cache
|
||||
local loaded = preferences.load()
|
||||
assert.is_true(loaded.auto_process)
|
||||
end)
|
||||
|
||||
it("should create .coder directory if it does not exist", function()
|
||||
-- Remove .coder directory
|
||||
vim.fn.delete(test_cwd .. "/.coder", "rf")
|
||||
|
||||
local prefs = { auto_process = false }
|
||||
preferences.save(prefs)
|
||||
|
||||
-- Directory should be created
|
||||
assert.equals(1, vim.fn.isdirectory(test_cwd .. "/.coder"))
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("get", function()
|
||||
it("should get a specific preference value", function()
|
||||
local path = test_cwd .. "/.coder/preferences.json"
|
||||
utils.write_file(path, '{"auto_process":true}')
|
||||
|
||||
local value = preferences.get("auto_process")
|
||||
|
||||
assert.is_true(value)
|
||||
end)
|
||||
|
||||
it("should return nil for non-existent key", function()
|
||||
local value = preferences.get("non_existent_key")
|
||||
|
||||
assert.is_nil(value)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("set", function()
|
||||
it("should set a specific preference value", function()
|
||||
preferences.set("auto_process", true)
|
||||
|
||||
local value = preferences.get("auto_process")
|
||||
assert.is_true(value)
|
||||
end)
|
||||
|
||||
it("should persist the value to file", function()
|
||||
preferences.set("auto_process", false)
|
||||
|
||||
-- Clear cache and reload
|
||||
preferences.clear_cache()
|
||||
local value = preferences.get("auto_process")
|
||||
|
||||
assert.is_false(value)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("is_auto_process_enabled", function()
|
||||
it("should return nil when not set", function()
|
||||
local result = preferences.is_auto_process_enabled()
|
||||
|
||||
assert.is_nil(result)
|
||||
end)
|
||||
|
||||
it("should return true when enabled", function()
|
||||
preferences.set("auto_process", true)
|
||||
|
||||
local result = preferences.is_auto_process_enabled()
|
||||
|
||||
assert.is_true(result)
|
||||
end)
|
||||
|
||||
it("should return false when disabled", function()
|
||||
preferences.set("auto_process", false)
|
||||
|
||||
local result = preferences.is_auto_process_enabled()
|
||||
|
||||
assert.is_false(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("set_auto_process", function()
|
||||
it("should set auto_process to true", function()
|
||||
preferences.set_auto_process(true)
|
||||
|
||||
assert.is_true(preferences.is_auto_process_enabled())
|
||||
assert.is_true(preferences.has_asked_auto_process())
|
||||
end)
|
||||
|
||||
it("should set auto_process to false", function()
|
||||
preferences.set_auto_process(false)
|
||||
|
||||
assert.is_false(preferences.is_auto_process_enabled())
|
||||
assert.is_true(preferences.has_asked_auto_process())
|
||||
end)
|
||||
|
||||
it("should also set asked_auto_process to true", function()
|
||||
preferences.set_auto_process(true)
|
||||
|
||||
assert.is_true(preferences.has_asked_auto_process())
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("has_asked_auto_process", function()
|
||||
it("should return false when not asked", function()
|
||||
local result = preferences.has_asked_auto_process()
|
||||
|
||||
assert.is_false(result)
|
||||
end)
|
||||
|
||||
it("should return true after setting auto_process", function()
|
||||
preferences.set_auto_process(true)
|
||||
|
||||
local result = preferences.has_asked_auto_process()
|
||||
|
||||
assert.is_true(result)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("clear_cache", function()
|
||||
it("should clear cached preferences", function()
|
||||
-- Load to populate cache
|
||||
local prefs = preferences.load()
|
||||
prefs.test_marker = "before_clear"
|
||||
|
||||
-- Clear cache
|
||||
preferences.clear_cache()
|
||||
|
||||
-- Load again - should not have the marker
|
||||
local prefs_after = preferences.load()
|
||||
assert.is_nil(prefs_after.test_marker)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("toggle_auto_process", function()
|
||||
it("should toggle from nil to true", function()
|
||||
-- Initially nil
|
||||
assert.is_nil(preferences.is_auto_process_enabled())
|
||||
|
||||
preferences.toggle_auto_process()
|
||||
|
||||
-- Should be true (not nil becomes true)
|
||||
assert.is_true(preferences.is_auto_process_enabled())
|
||||
end)
|
||||
|
||||
it("should toggle from true to false", function()
|
||||
preferences.set_auto_process(true)
|
||||
|
||||
preferences.toggle_auto_process()
|
||||
|
||||
assert.is_false(preferences.is_auto_process_enabled())
|
||||
end)
|
||||
|
||||
it("should toggle from false to true", function()
|
||||
preferences.set_auto_process(false)
|
||||
|
||||
preferences.toggle_auto_process()
|
||||
|
||||
assert.is_true(preferences.is_auto_process_enabled())
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -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
269
tests/spec/worker_spec.lua
Normal 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)
|
||||
Reference in New Issue
Block a user