fixing unable files

This commit is contained in:
2026-02-16 11:12:22 -05:00
parent 4bef7e2d55
commit e534d607d8
62 changed files with 11915 additions and 944 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,907 @@
---@mod codetyper.agent.ui Agent chat UI for Codetyper.nvim
---
--- Provides a sidebar chat interface for agent interactions with real-time logs.
local M = {}
local agent = require("codetyper.features.agents")
local logs = require("codetyper.adapters.nvim.ui.logs")
local utils = require("codetyper.support.utils")
---@class AgentUIState
---@field chat_buf number|nil Chat buffer
---@field chat_win number|nil Chat window
---@field input_buf number|nil Input buffer
---@field input_win number|nil Input window
---@field logs_buf number|nil Logs buffer
---@field logs_win number|nil Logs window
---@field is_open boolean Whether the UI is open
---@field log_listener_id number|nil Listener ID for logs
---@field referenced_files table Files referenced with @
local state = {
chat_buf = nil,
chat_win = nil,
input_buf = nil,
input_win = nil,
logs_buf = nil,
logs_win = nil,
is_open = false,
log_listener_id = nil,
referenced_files = {},
selection_context = nil, -- Visual selection passed when opening
}
--- Namespace for highlights
local ns_chat = vim.api.nvim_create_namespace("codetyper_agent_chat")
local ns_logs = vim.api.nvim_create_namespace("codetyper_agent_logs")
--- Fixed heights
local INPUT_HEIGHT = 5
local LOGS_WIDTH = 50
--- Calculate dynamic width (1/4 of screen, minimum 30)
---@return number
local function get_panel_width()
return math.max(math.floor(vim.o.columns * 0.25), 30)
end
--- Autocmd group
local agent_augroup = nil
--- Autocmd group for width maintenance
local width_augroup = nil
--- Store target width
local target_width = nil
--- Setup autocmd to always maintain 1/4 window width
local function setup_width_autocmd()
-- Clear previous autocmd group if exists
if width_augroup then
pcall(vim.api.nvim_del_augroup_by_id, width_augroup)
end
width_augroup = vim.api.nvim_create_augroup("CodetypeAgentWidth", { clear = true })
-- Always maintain 1/4 width on any window event
vim.api.nvim_create_autocmd({ "WinResized", "WinNew", "WinClosed", "VimResized" }, {
group = width_augroup,
callback = function()
if not state.is_open or not state.chat_win then
return
end
if not vim.api.nvim_win_is_valid(state.chat_win) then
return
end
vim.schedule(function()
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
-- Always calculate 1/4 of current screen width
local new_target = math.max(math.floor(vim.o.columns * 0.25), 30)
target_width = new_target
local current_width = vim.api.nvim_win_get_width(state.chat_win)
if current_width ~= target_width then
pcall(vim.api.nvim_win_set_width, state.chat_win, target_width)
end
end
end)
end,
desc = "Maintain Agent panel at 1/4 window width",
})
end
--- Add a log entry to the logs buffer
---@param entry table Log entry
local function add_log_entry(entry)
if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then
return
end
vim.schedule(function()
if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then
return
end
-- Handle clear event
if entry.level == "clear" then
vim.bo[state.logs_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, {
"Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.logs_buf].modifiable = false
return
end
vim.bo[state.logs_buf].modifiable = true
local formatted = logs.format_entry(entry)
local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, -1, false)
local line_num = #lines
-- Split formatted log into individual lines to avoid passing newline-containing items
local formatted_lines = vim.split(formatted, "\n")
vim.api.nvim_buf_set_lines(state.logs_buf, -1, -1, false, formatted_lines)
-- Apply highlighting based on level
local hl_map = {
info = "DiagnosticInfo",
debug = "Comment",
request = "DiagnosticWarn",
response = "DiagnosticOk",
tool = "DiagnosticHint",
error = "DiagnosticError",
}
local hl = hl_map[entry.level] or "Normal"
vim.api.nvim_buf_add_highlight(state.logs_buf, ns_logs, hl, line_num, 0, -1)
vim.bo[state.logs_buf].modifiable = false
-- Auto-scroll logs
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
local new_count = vim.api.nvim_buf_line_count(state.logs_buf)
pcall(vim.api.nvim_win_set_cursor, state.logs_win, { new_count, 0 })
end
end)
end
--- Add a message to the chat buffer
---@param role string "user" | "assistant" | "tool" | "system"
---@param content string Message content
---@param highlight? string Optional highlight group
local function add_message(role, content, highlight)
if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then
return
end
vim.bo[state.chat_buf].modifiable = true
local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false)
local start_line = #lines
-- Add separator if not first message
if start_line > 0 and lines[start_line] ~= "" then
vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, { "" })
start_line = start_line + 1
end
-- Format the message
local prefix_map = {
user = ">>> You:",
assistant = "<<< Agent:",
tool = "[Tool]",
system = "[System]",
}
local prefix = prefix_map[role] or "[Unknown]"
local message_lines = { prefix }
-- Split content into lines
for line in content:gmatch("[^\n]+") do
table.insert(message_lines, " " .. line)
end
vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, message_lines)
-- Apply highlighting
local hl_group = highlight or ({
user = "DiagnosticInfo",
assistant = "DiagnosticOk",
tool = "DiagnosticWarn",
system = "DiagnosticHint",
})[role] or "Normal"
vim.api.nvim_buf_add_highlight(state.chat_buf, ns_chat, hl_group, start_line, 0, -1)
vim.bo[state.chat_buf].modifiable = false
-- Scroll to bottom
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
local line_count = vim.api.nvim_buf_line_count(state.chat_buf)
pcall(vim.api.nvim_win_set_cursor, state.chat_win, { line_count, 0 })
end
end
--- Create the agent callbacks
---@return table Callbacks for agent.run
local function create_callbacks()
return {
on_text = function(text)
vim.schedule(function()
add_message("assistant", text)
logs.thinking("Received response text")
end)
end,
on_tool_start = function(name)
vim.schedule(function()
add_message("tool", "Executing: " .. name .. "...", "DiagnosticWarn")
logs.tool(name, "start")
end)
end,
on_tool_result = function(name, result)
vim.schedule(function()
local display_result = result
if #result > 200 then
display_result = result:sub(1, 200) .. "..."
end
add_message("tool", name .. ": " .. display_result, "DiagnosticOk")
logs.tool(name, "success", string.format("%d bytes", #result))
end)
end,
on_complete = function()
vim.schedule(function()
local changes_count = agent.get_changes_count()
if changes_count > 0 then
add_message("system",
string.format("Done. %d file(s) changed. Press <leader>d to review changes.", changes_count),
"DiagnosticHint")
logs.info(string.format("Agent completed with %d change(s)", changes_count))
else
add_message("system", "Done.", "DiagnosticHint")
logs.info("Agent loop completed")
end
M.focus_input()
end)
end,
on_error = function(err)
vim.schedule(function()
add_message("system", "Error: " .. err, "DiagnosticError")
logs.error(err)
M.focus_input()
end)
end,
}
end
--- Build file context from referenced files
---@return string Context string
local function build_file_context()
local context = ""
for filename, filepath in pairs(state.referenced_files) do
local content = utils.read_file(filepath)
if content and content ~= "" then
local ext = vim.fn.fnamemodify(filepath, ":e")
context = context .. "\n\n=== FILE: " .. filename .. " ===\n"
context = context .. "Path: " .. filepath .. "\n"
context = context .. "```" .. (ext or "text") .. "\n" .. content .. "\n```\n"
end
end
return context
end
--- Submit user input
local function submit_input()
if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then
return
end
local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false)
local input = table.concat(lines, "\n")
input = vim.trim(input)
if input == "" then
return
end
-- Clear input buffer
vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, { "" })
-- Handle special commands
if input == "/stop" then
agent.stop()
add_message("system", "Stopped.")
logs.info("Agent stopped by user")
return
end
if input == "/clear" then
agent.reset()
logs.clear()
state.referenced_files = {}
if state.chat_buf and vim.api.nvim_buf_is_valid(state.chat_buf) then
vim.bo[state.chat_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, {
"╔═══════════════════════════════════════════════════════════════╗",
"║ [AGENT MODE] Can read/write files ║",
"╠═══════════════════════════════════════════════════════════════╣",
"║ @ attach | C-f current file | <leader>d review changes ║",
"╚═══════════════════════════════════════════════════════════════╝",
"",
})
vim.bo[state.chat_buf].modifiable = false
end
-- Also clear collected diffs
local diff_review = require("codetyper.adapters.nvim.ui.diff_review")
diff_review.clear()
return
end
if input == "/close" then
M.close()
return
end
if input == "/continue" then
if agent.is_running() then
add_message("system", "Agent is already running. Use /stop first.")
return
end
if not agent.has_saved_session() then
add_message("system", "No saved session to continue.")
return
end
local info = agent.get_saved_session_info()
if info then
add_message("system", string.format("Resuming session from %s...", info.saved_at))
logs.info(string.format("Resuming: %d messages, iteration %d", info.messages, info.iteration))
end
local success = agent.continue_session(create_callbacks())
if not success then
add_message("system", "Failed to resume session.")
end
return
end
-- Build file context
local file_context = build_file_context()
local file_count = vim.tbl_count(state.referenced_files)
-- Add user message to chat
local display_input = input
if file_count > 0 then
local files_list = {}
for fname, _ in pairs(state.referenced_files) do
table.insert(files_list, fname)
end
display_input = input .. "\n[Attached: " .. table.concat(files_list, ", ") .. "]"
end
add_message("user", display_input)
logs.info("User: " .. input:sub(1, 40) .. (input:len() > 40 and "..." or ""))
-- Clear referenced files after use
state.referenced_files = {}
-- Check if agent is already running
if agent.is_running() then
add_message("system", "Busy. /stop first.")
logs.info("Request rejected - busy")
return
end
-- Build context from current buffer
local current_file = vim.fn.expand("#:p")
if current_file == "" then
current_file = vim.fn.expand("%:p")
end
local llm = require("codetyper.core.llm")
local context = {}
if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then
context = llm.build_context(current_file, "agent")
logs.debug("Context: " .. vim.fn.fnamemodify(current_file, ":t"))
end
-- Append file context to input
local full_input = input
-- Add selection context if present
local selection_ctx = M.get_selection_context()
if selection_ctx then
full_input = full_input .. "\n\n" .. selection_ctx
end
if file_context ~= "" then
full_input = full_input .. "\n\nATTACHED FILES:" .. file_context
end
logs.thinking("Starting...")
-- Run the agent
agent.run(full_input, context, create_callbacks())
end
--- Show file picker for @ mentions
function M.show_file_picker()
local has_telescope, telescope = pcall(require, "telescope.builtin")
if has_telescope then
telescope.find_files({
prompt_title = "Attach file (@)",
attach_mappings = function(prompt_bufnr, map)
local actions = require("telescope.actions")
local action_state = require("telescope.actions.state")
actions.select_default:replace(function()
actions.close(prompt_bufnr)
local selection = action_state.get_selected_entry()
if selection then
local filepath = selection.path or selection[1]
local filename = vim.fn.fnamemodify(filepath, ":t")
M.add_file_reference(filepath, filename)
end
end)
return true
end,
})
else
vim.ui.input({ prompt = "File path: " }, function(input)
if input and input ~= "" then
local filepath = vim.fn.fnamemodify(input, ":p")
local filename = vim.fn.fnamemodify(filepath, ":t")
M.add_file_reference(filepath, filename)
end
end)
end
end
--- Add a file reference
---@param filepath string Full path to the file
---@param filename string Display name
function M.add_file_reference(filepath, filename)
filepath = vim.fn.fnamemodify(filepath, ":p")
state.referenced_files[filename] = filepath
local content = utils.read_file(filepath)
if not content then
utils.notify("Cannot read: " .. filename, vim.log.levels.WARN)
return
end
add_message("system", "Attached: " .. filename, "DiagnosticHint")
logs.debug("Attached: " .. filename)
M.focus_input()
end
--- Include current file context
function M.include_current_file()
-- Get the file from the window that's not the agent sidebar
local current_file = nil
for _, win in ipairs(vim.api.nvim_list_wins()) do
if win ~= state.chat_win and win ~= state.logs_win and win ~= state.input_win then
local buf = vim.api.nvim_win_get_buf(win)
local name = vim.api.nvim_buf_get_name(buf)
if name ~= "" and vim.fn.filereadable(name) == 1 then
current_file = name
break
end
end
end
if not current_file then
utils.notify("No file to attach", vim.log.levels.WARN)
return
end
local filename = vim.fn.fnamemodify(current_file, ":t")
M.add_file_reference(current_file, filename)
end
--- Focus the input buffer
function M.focus_input()
if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then
vim.api.nvim_set_current_win(state.input_win)
vim.cmd("startinsert")
end
end
--- Focus the chat buffer
function M.focus_chat()
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
vim.api.nvim_set_current_win(state.chat_win)
end
end
--- Focus the logs buffer
function M.focus_logs()
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
vim.api.nvim_set_current_win(state.logs_win)
end
end
--- Show chat mode switcher modal
function M.show_chat_switcher()
local switcher = require("codetyper.chat_switcher")
switcher.show()
end
--- Update the logs title with token counts
local function update_logs_title()
if not state.logs_win or not vim.api.nvim_win_is_valid(state.logs_win) then
return
end
local prompt_tokens, response_tokens = logs.get_token_totals()
local provider, _ = logs.get_provider_info()
if provider and state.logs_buf and vim.api.nvim_buf_is_valid(state.logs_buf) then
vim.bo[state.logs_buf].modifiable = true
local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, 2, false)
if #lines >= 1 then
lines[1] = string.format("%s | %d/%d tokens", provider:upper(), prompt_tokens, response_tokens)
vim.api.nvim_buf_set_lines(state.logs_buf, 0, 1, false, { lines[1] })
end
vim.bo[state.logs_buf].modifiable = false
end
end
--- Open the agent UI
---@param selection table|nil Visual selection context {text, start_line, end_line, filepath, filename, language}
function M.open(selection)
if state.is_open then
-- If already open and new selection provided, add it as context
if selection and selection.text and selection.text ~= "" then
M.add_selection_context(selection)
end
M.focus_input()
return
end
-- Store selection context
state.selection_context = selection
-- Clear previous state
logs.clear()
state.referenced_files = {}
-- Create chat buffer
state.chat_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.chat_buf].buftype = "nofile"
vim.bo[state.chat_buf].bufhidden = "hide"
vim.bo[state.chat_buf].swapfile = false
vim.bo[state.chat_buf].filetype = "markdown"
-- Create input buffer
state.input_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.input_buf].buftype = "nofile"
vim.bo[state.input_buf].bufhidden = "hide"
vim.bo[state.input_buf].swapfile = false
-- Create logs buffer
state.logs_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.logs_buf].buftype = "nofile"
vim.bo[state.logs_buf].bufhidden = "hide"
vim.bo[state.logs_buf].swapfile = false
-- Create chat window on the LEFT (like NvimTree)
vim.cmd("topleft vsplit")
state.chat_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.chat_win, state.chat_buf)
vim.api.nvim_win_set_width(state.chat_win, get_panel_width())
-- Window options for chat
vim.wo[state.chat_win].number = false
vim.wo[state.chat_win].relativenumber = false
vim.wo[state.chat_win].signcolumn = "no"
vim.wo[state.chat_win].wrap = true
vim.wo[state.chat_win].linebreak = true
vim.wo[state.chat_win].winfixwidth = true
vim.wo[state.chat_win].cursorline = false
-- Create input window below chat
vim.cmd("belowright split")
state.input_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.input_win, state.input_buf)
vim.api.nvim_win_set_height(state.input_win, INPUT_HEIGHT)
-- Window options for input
vim.wo[state.input_win].number = false
vim.wo[state.input_win].relativenumber = false
vim.wo[state.input_win].signcolumn = "no"
vim.wo[state.input_win].wrap = true
vim.wo[state.input_win].linebreak = true
vim.wo[state.input_win].winfixheight = true
vim.wo[state.input_win].winfixwidth = true
-- Create logs window on the RIGHT
vim.cmd("botright vsplit")
state.logs_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.logs_win, state.logs_buf)
vim.api.nvim_win_set_width(state.logs_win, LOGS_WIDTH)
-- Window options for logs
vim.wo[state.logs_win].number = false
vim.wo[state.logs_win].relativenumber = false
vim.wo[state.logs_win].signcolumn = "no"
vim.wo[state.logs_win].wrap = true
vim.wo[state.logs_win].linebreak = true
vim.wo[state.logs_win].winfixwidth = true
vim.wo[state.logs_win].cursorline = false
-- Set initial content for chat
vim.bo[state.chat_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, {
"╔═══════════════════════════════════════════════════════════════╗",
"║ [AGENT MODE] Can read/write files ║",
"╠═══════════════════════════════════════════════════════════════╣",
"║ @ attach | C-f current file | <leader>d review changes ║",
"╚═══════════════════════════════════════════════════════════════╝",
"",
})
vim.bo[state.chat_buf].modifiable = false
-- Set initial content for logs
vim.bo[state.logs_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, {
"Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.logs_buf].modifiable = false
-- Register log listener
state.log_listener_id = logs.add_listener(function(entry)
add_log_entry(entry)
if entry.level == "response" then
vim.schedule(update_logs_title)
end
end)
-- Set up keymaps for input buffer
local input_opts = { buffer = state.input_buf, noremap = true, silent = true }
vim.keymap.set("i", "<CR>", submit_input, input_opts)
vim.keymap.set("n", "<CR>", submit_input, input_opts)
vim.keymap.set("i", "@", M.show_file_picker, input_opts)
vim.keymap.set({ "n", "i" }, "<C-f>", M.include_current_file, input_opts)
vim.keymap.set("n", "<Tab>", M.focus_chat, input_opts)
vim.keymap.set("n", "q", M.close, input_opts)
vim.keymap.set("n", "<Esc>", M.close, input_opts)
vim.keymap.set("n", "<leader>d", M.show_diff_review, input_opts)
-- Set up keymaps for chat buffer
local chat_opts = { buffer = state.chat_buf, noremap = true, silent = true }
vim.keymap.set("n", "i", M.focus_input, chat_opts)
vim.keymap.set("n", "<CR>", M.focus_input, chat_opts)
vim.keymap.set("n", "@", M.show_file_picker, chat_opts)
vim.keymap.set("n", "<C-f>", M.include_current_file, chat_opts)
vim.keymap.set("n", "<Tab>", M.focus_logs, chat_opts)
vim.keymap.set("n", "q", M.close, chat_opts)
vim.keymap.set("n", "<leader>d", M.show_diff_review, chat_opts)
-- Set up keymaps for logs buffer
local logs_opts = { buffer = state.logs_buf, noremap = true, silent = true }
vim.keymap.set("n", "<Tab>", M.focus_input, logs_opts)
vim.keymap.set("n", "q", M.close, logs_opts)
vim.keymap.set("n", "i", M.focus_input, logs_opts)
-- Setup autocmd for cleanup
agent_augroup = vim.api.nvim_create_augroup("CodetypeAgentUI", { clear = true })
vim.api.nvim_create_autocmd("WinClosed", {
group = agent_augroup,
callback = function(args)
local closed_win = tonumber(args.match)
if closed_win == state.chat_win or closed_win == state.logs_win or closed_win == state.input_win then
vim.schedule(function()
M.close()
end)
end
end,
})
-- Setup autocmd to maintain 1/4 width
target_width = get_panel_width()
setup_width_autocmd()
state.is_open = true
-- Focus input and log startup
M.focus_input()
logs.info("Agent ready")
-- Check for saved session and notify user
if agent.has_saved_session() then
vim.schedule(function()
local info = agent.get_saved_session_info()
if info then
add_message("system",
string.format("Saved session available (%s). Type /continue to resume.", info.saved_at),
"DiagnosticHint")
logs.info("Saved session found: " .. (info.prompt or ""):sub(1, 30) .. "...")
end
end)
end
-- If we have a selection, show it as context
if selection and selection.text and selection.text ~= "" then
vim.schedule(function()
M.add_selection_context(selection)
end)
end
-- Log provider info
local ok, codetyper = pcall(require, "codetyper")
if ok then
local config = codetyper.get_config()
local provider = config.llm.provider
local model = "unknown"
if provider == "ollama" then
model = config.llm.ollama.model
elseif provider == "openai" then
model = config.llm.openai.model
elseif provider == "gemini" then
model = config.llm.gemini.model
elseif provider == "copilot" then
model = config.llm.copilot.model
end
logs.info(string.format("%s (%s)", provider, model))
end
end
--- Close the agent UI
function M.close()
if not state.is_open then
return
end
-- Stop agent if running
if agent.is_running() then
agent.stop()
end
-- Remove log listener
if state.log_listener_id then
logs.remove_listener(state.log_listener_id)
state.log_listener_id = nil
end
-- Remove autocmd
if agent_augroup then
pcall(vim.api.nvim_del_augroup_by_id, agent_augroup)
agent_augroup = nil
end
-- Close windows
if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then
pcall(vim.api.nvim_win_close, state.input_win, true)
end
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
pcall(vim.api.nvim_win_close, state.chat_win, true)
end
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
pcall(vim.api.nvim_win_close, state.logs_win, true)
end
-- Reset state
state.chat_buf = nil
state.chat_win = nil
state.input_buf = nil
state.input_win = nil
state.logs_buf = nil
state.logs_win = nil
state.is_open = false
state.referenced_files = {}
-- Reset agent conversation
agent.reset()
end
--- Toggle the agent UI
function M.toggle()
if state.is_open then
M.close()
else
M.open()
end
end
--- Check if UI is open
---@return boolean
function M.is_open()
return state.is_open
end
--- Show the diff review for all changes made in this session
function M.show_diff_review()
local changes_count = agent.get_changes_count()
if changes_count == 0 then
utils.notify("No changes to review", vim.log.levels.INFO)
return
end
agent.show_diff_review()
end
--- Add visual selection as context in the chat
---@param selection table Selection info {text, start_line, end_line, filepath, filename, language}
function M.add_selection_context(selection)
if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then
return
end
state.selection_context = selection
vim.bo[state.chat_buf].modifiable = true
local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false)
-- Format the selection display
local location = ""
if selection.filename then
location = selection.filename
if selection.start_line then
location = location .. ":" .. selection.start_line
if selection.end_line and selection.end_line ~= selection.start_line then
location = location .. "-" .. selection.end_line
end
end
end
local new_lines = {
"",
"┌─ Selected Code ─────────────────────",
"" .. location,
"",
}
-- Add the selected code
for _, line in ipairs(vim.split(selection.text, "\n")) do
table.insert(new_lines, "" .. line)
end
table.insert(new_lines, "")
table.insert(new_lines, "└──────────────────────────────────────")
table.insert(new_lines, "")
table.insert(new_lines, "Describe what you'd like to do with this code.")
for _, line in ipairs(new_lines) do
table.insert(lines, line)
end
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, lines)
vim.bo[state.chat_buf].modifiable = false
-- Scroll to bottom
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
local line_count = vim.api.nvim_buf_line_count(state.chat_buf)
vim.api.nvim_win_set_cursor(state.chat_win, { line_count, 0 })
end
-- Also add the file to referenced_files for context
if selection.filepath and selection.filepath ~= "" then
state.referenced_files[selection.filename or "selection"] = selection.filepath
end
logs.info("Selection added: " .. location)
end
--- Get selection context for agent prompt
---@return string|nil Selection context string
function M.get_selection_context()
if not state.selection_context or not state.selection_context.text then
return nil
end
local sel = state.selection_context
local location = sel.filename or "unknown"
if sel.start_line then
location = location .. ":" .. sel.start_line
if sel.end_line and sel.end_line ~= sel.start_line then
location = location .. "-" .. sel.end_line
end
end
return string.format(
"SELECTED CODE (%s):\n```%s\n%s\n```",
location,
sel.language or "",
sel.text
)
end
return M

View File

@@ -0,0 +1,381 @@
---@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,
attached_files = 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
-- Pass attached_files as third optional parameter
callback(original_event, additional_context, state.attached_files)
end
end
--- Parse requested file paths from LLM response and resolve to full paths
local function parse_requested_files(response)
if not response or response == "" then
return {}
end
local cwd = vim.fn.getcwd()
local candidates = {}
local seen = {}
for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do
if not seen[path] then
table.insert(candidates, path)
seen[path] = true
end
end
for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do
if not seen[path] then
table.insert(candidates, path)
seen[path] = true
end
end
-- Resolve to full paths using cwd and glob
local resolved = {}
for _, p in ipairs(candidates) do
local full = nil
if p:sub(1,1) == "/" and vim.fn.filereadable(p) == 1 then
full = p
else
local try1 = cwd .. "/" .. p
if vim.fn.filereadable(try1) == 1 then
full = try1
else
local tail = p:match("[^/]+$") or p
local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true)
if matches and #matches > 0 then
full = matches[1]
end
end
end
if full and vim.fn.filereadable(full) == 1 then
table.insert(resolved, full)
end
end
return resolved
end
--- Attach parsed files into the modal buffer and remember them for submission
local function attach_requested_files()
if not state.llm_response or state.llm_response == "" then
return
end
local files = parse_requested_files(state.llm_response)
if #files == 0 then
local ui_prompts = require("codetyper.prompts.agents.modal").ui
vim.api.nvim_buf_set_lines(state.buf, vim.api.nvim_buf_line_count(state.buf), -1, false, ui_prompts.files_header)
return
end
state.attached_files = state.attached_files or {}
for _, full in ipairs(files) do
local ok, lines = pcall(vim.fn.readfile, full)
if ok and lines and #lines > 0 then
table.insert(state.attached_files, { path = vim.fn.fnamemodify(full, ":~:." ) , full_path = full, content = table.concat(lines, "\n") })
local insert_at = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Attached: " .. full .. " --" })
for i, l in ipairs(lines) do
vim.api.nvim_buf_set_lines(state.buf, insert_at + 1 + i, insert_at + 1 + i, false, { l })
end
else
local insert_at = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Failed to read: " .. full .. " --" })
end
end
-- Move cursor to end and enter insert mode
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
vim.cmd("startinsert")
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, attached_files?: table)
---@param suggested_commands table[]|nil Optional list of {label,cmd} suggested shell commands
function M.open(original_event, llm_response, callback, suggested_commands)
-- 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
local ui_prompts = require("codetyper.prompts.agents.modal").ui
-- Add header showing what the LLM said
local header_lines = {
ui_prompts.llm_response_header,
}
-- 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
-- If suggested commands were provided, show them in the header
if suggested_commands and #suggested_commands > 0 then
table.insert(header_lines, "")
table.insert(header_lines, ui_prompts.suggested_commands_header)
for i, s in ipairs(suggested_commands) do
local label = s.label or s.cmd
table.insert(header_lines, string.format("[%d] %s: %s", i, label, s.cmd))
end
table.insert(header_lines, ui_prompts.commands_hint)
end
table.insert(header_lines, "")
table.insert(header_lines, ui_prompts.input_header)
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)
-- Attach parsed files (from LLM response)
vim.keymap.set("n", "a", function()
attach_requested_files()
end, opts)
-- Confirm and submit with 'c' (convenient when doing question round)
vim.keymap.set("n", "c", submit, opts)
-- Quick run of project inspection from modal with <leader>r / <C-r> in insert mode
vim.keymap.set("n", "<leader>r", run_project_inspect, opts)
vim.keymap.set("i", "<C-r>", function()
vim.schedule(run_project_inspect)
end, { buffer = state.buf, noremap = true, silent = true })
-- If suggested commands provided, create per-command keymaps <leader>1..n to run them
state.suggested_commands = suggested_commands
if suggested_commands and #suggested_commands > 0 then
for i, s in ipairs(suggested_commands) do
local key = "<leader>" .. tostring(i)
vim.keymap.set("n", key, function()
-- run this single command and append output
if not s or not s.cmd then
return
end
local ok, out = pcall(vim.fn.systemlist, s.cmd)
local insert_at = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" })
if ok and out and #out > 0 then
for j, line in ipairs(out) do
vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line })
end
else
vim.api.nvim_buf_set_lines(state.buf, insert_at + 1, insert_at + 1, false, { "(no output or command failed)" })
end
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
vim.cmd("startinsert")
end, opts)
end
-- Also map <leader>0 to run all suggested commands
vim.keymap.set("n", "<leader>0", function()
for _, s in ipairs(suggested_commands) do
pcall(function()
local ok, out = pcall(vim.fn.systemlist, s.cmd)
local insert_at = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" })
if ok and out and #out > 0 then
for j, line in ipairs(out) do
vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line })
end
else
vim.api.nvim_buf_set_lines(state.buf, insert_at + 1, insert_at + 1, false, { "(no output or command failed)" })
end
end)
end
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
vim.cmd("startinsert")
end, opts)
end
-- 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.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Context modal opened - waiting for user input",
})
end)
end
--- Run a small set of safe project inspection commands and insert outputs into the modal buffer
local function run_project_inspect()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
local cmds = {
{ label = "List files (ls -la)", cmd = "ls -la" },
{ label = "Git status (git status --porcelain)", cmd = "git status --porcelain" },
{ label = "Git top (git rev-parse --show-toplevel)", cmd = "git rev-parse --show-toplevel" },
{ label = "Show repo files (git ls-files)", cmd = "git ls-files" },
}
local ui_prompts = require("codetyper.prompts.agents.modal").ui
local insert_pos = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, insert_pos, insert_pos, false, ui_prompts.project_inspect_header)
for _, c in ipairs(cmds) do
local ok, out = pcall(vim.fn.systemlist, c.cmd)
if ok and out and #out > 0 then
vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --" })
for i, line in ipairs(out) do
vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2 + i, insert_pos + 2 + i, false, { line })
end
insert_pos = vim.api.nvim_buf_line_count(state.buf)
else
vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --", "(no output or command failed)" })
insert_pos = vim.api.nvim_buf_line_count(state.buf)
end
end
-- Move cursor to end
vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 })
vim.cmd("startinsert")
end
-- Provide a keybinding in the modal to run project inspection commands
pcall(function()
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
vim.keymap.set("n", "<leader>r", run_project_inspect, { buffer = state.buf, noremap = true, silent = true })
vim.keymap.set("i", "<C-r>", function()
vim.schedule(run_project_inspect)
end, { buffer = state.buf, noremap = true, silent = true })
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

View File

@@ -0,0 +1,386 @@
---@mod codetyper.agent.diff_review Diff review UI for agent changes
---
--- Provides a lazygit-style window interface for reviewing all changes
--- made during an agent session.
local M = {}
local utils = require("codetyper.support.utils")
local prompts = require("codetyper.prompts.agents.diff")
---@class DiffEntry
---@field path string File path
---@field operation string "create"|"edit"|"delete"
---@field original string|nil Original content (nil for new files)
---@field modified string New/modified content
---@field approved boolean Whether change was approved
---@field applied boolean Whether change was applied
---@class DiffReviewState
---@field entries DiffEntry[] List of changes
---@field current_index number Currently selected entry
---@field list_buf number|nil File list buffer
---@field list_win number|nil File list window
---@field diff_buf number|nil Diff view buffer
---@field diff_win number|nil Diff view window
---@field is_open boolean Whether review UI is open
local state = {
entries = {},
current_index = 1,
list_buf = nil,
list_win = nil,
diff_buf = nil,
diff_win = nil,
is_open = false,
}
--- Clear all collected diffs
function M.clear()
state.entries = {}
state.current_index = 1
end
--- Add a diff entry
---@param entry DiffEntry
function M.add(entry)
table.insert(state.entries, entry)
end
--- Get all entries
---@return DiffEntry[]
function M.get_entries()
return state.entries
end
--- Get entry count
---@return number
function M.count()
return #state.entries
end
--- Generate unified diff between two strings
---@param original string|nil
---@param modified string
---@param filepath string
---@return string[]
local function generate_diff_lines(original, modified, filepath)
local lines = {}
local filename = vim.fn.fnamemodify(filepath, ":t")
if not original then
-- New file
table.insert(lines, "--- /dev/null")
table.insert(lines, "+++ b/" .. filename)
table.insert(lines, "@@ -0,0 +1," .. #vim.split(modified, "\n") .. " @@")
for _, line in ipairs(vim.split(modified, "\n")) do
table.insert(lines, "+" .. line)
end
else
-- Modified file - use vim's diff
table.insert(lines, "--- a/" .. filename)
table.insert(lines, "+++ b/" .. filename)
local orig_lines = vim.split(original, "\n")
local mod_lines = vim.split(modified, "\n")
-- Simple diff: show removed and added lines
local max_lines = math.max(#orig_lines, #mod_lines)
local context_start = 1
local in_change = false
for i = 1, max_lines do
local orig = orig_lines[i] or ""
local mod = mod_lines[i] or ""
if orig ~= mod then
if not in_change then
table.insert(lines, string.format("@@ -%d,%d +%d,%d @@",
math.max(1, i - 2), math.min(5, #orig_lines - i + 3),
math.max(1, i - 2), math.min(5, #mod_lines - i + 3)))
in_change = true
end
if orig ~= "" then
table.insert(lines, "-" .. orig)
end
if mod ~= "" then
table.insert(lines, "+" .. mod)
end
else
if in_change then
table.insert(lines, " " .. orig)
in_change = false
end
end
end
end
return lines
end
--- Update the diff view for current entry
local function update_diff_view()
if not state.diff_buf or not vim.api.nvim_buf_is_valid(state.diff_buf) then
return
end
local entry = state.entries[state.current_index]
local ui_prompts = prompts.review
if not entry then
vim.bo[state.diff_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, { ui_prompts.messages.no_changes_short })
vim.bo[state.diff_buf].modifiable = false
return
end
local lines = {}
-- Header
local status_icon = entry.applied and " " or (entry.approved and " " or " ")
local op_icon = entry.operation == "create" and "+" or (entry.operation == "delete" and "-" or "~")
local current_status = entry.applied and ui_prompts.status.applied
or (entry.approved and ui_prompts.status.approved or ui_prompts.status.pending)
table.insert(lines, string.format(ui_prompts.diff_header.top,
status_icon, op_icon, vim.fn.fnamemodify(entry.path, ":t")))
table.insert(lines, string.format(ui_prompts.diff_header.path, entry.path))
table.insert(lines, string.format(ui_prompts.diff_header.op, entry.operation))
table.insert(lines, string.format(ui_prompts.diff_header.status, current_status))
table.insert(lines, ui_prompts.diff_header.bottom)
table.insert(lines, "")
-- Diff content
local diff_lines = generate_diff_lines(entry.original, entry.modified, entry.path)
for _, line in ipairs(diff_lines) do
table.insert(lines, line)
end
vim.bo[state.diff_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, lines)
vim.bo[state.diff_buf].modifiable = false
vim.bo[state.diff_buf].filetype = "diff"
end
--- Update the file list
local function update_file_list()
if not state.list_buf or not vim.api.nvim_buf_is_valid(state.list_buf) then
return
end
local ui_prompts = prompts.review
local lines = {}
table.insert(lines, string.format(ui_prompts.list_menu.top, #state.entries))
for _, item in ipairs(ui_prompts.list_menu.items) do
table.insert(lines, item)
end
table.insert(lines, ui_prompts.list_menu.bottom)
table.insert(lines, "")
for i, entry in ipairs(state.entries) do
local prefix = (i == state.current_index) and "" or " "
local status = entry.applied and "" or (entry.approved and "" or "")
local op = entry.operation == "create" and "[+]" or (entry.operation == "delete" and "[-]" or "[~]")
local filename = vim.fn.fnamemodify(entry.path, ":t")
table.insert(lines, string.format("%s%s %s %s", prefix, status, op, filename))
end
if #state.entries == 0 then
table.insert(lines, ui_prompts.messages.no_changes)
end
vim.bo[state.list_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.list_buf, 0, -1, false, lines)
vim.bo[state.list_buf].modifiable = false
-- Highlight current line
if state.list_win and vim.api.nvim_win_is_valid(state.list_win) then
local target_line = 9 + state.current_index - 1
if target_line <= vim.api.nvim_buf_line_count(state.list_buf) then
vim.api.nvim_win_set_cursor(state.list_win, { target_line, 0 })
end
end
end
--- Navigate to next entry
function M.next()
if state.current_index < #state.entries then
state.current_index = state.current_index + 1
update_file_list()
update_diff_view()
end
end
--- Navigate to previous entry
function M.prev()
if state.current_index > 1 then
state.current_index = state.current_index - 1
update_file_list()
update_diff_view()
end
end
--- Approve current entry
function M.approve_current()
local entry = state.entries[state.current_index]
if entry and not entry.applied then
entry.approved = true
update_file_list()
update_diff_view()
end
end
--- Reject current entry
function M.reject_current()
local entry = state.entries[state.current_index]
if entry and not entry.applied then
entry.approved = false
update_file_list()
update_diff_view()
end
end
--- Approve all entries
function M.approve_all()
for _, entry in ipairs(state.entries) do
if not entry.applied then
entry.approved = true
end
end
update_file_list()
update_diff_view()
end
--- Apply approved changes
function M.apply_approved()
local applied_count = 0
for _, entry in ipairs(state.entries) do
if entry.approved and not entry.applied then
if entry.operation == "create" or entry.operation == "edit" then
local ok = utils.write_file(entry.path, entry.modified)
if ok then
entry.applied = true
applied_count = applied_count + 1
end
elseif entry.operation == "delete" then
local ok = os.remove(entry.path)
if ok then
entry.applied = true
applied_count = applied_count + 1
end
end
end
end
update_file_list()
update_diff_view()
if applied_count > 0 then
utils.notify(string.format(prompts.review.messages.applied_count, applied_count))
end
return applied_count
end
--- Open the diff review UI
function M.open()
if state.is_open then
return
end
if #state.entries == 0 then
utils.notify(prompts.review.messages.no_changes_short, vim.log.levels.INFO)
return
end
-- Create list buffer
state.list_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.list_buf].buftype = "nofile"
vim.bo[state.list_buf].bufhidden = "wipe"
vim.bo[state.list_buf].swapfile = false
-- Create diff buffer
state.diff_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.diff_buf].buftype = "nofile"
vim.bo[state.diff_buf].bufhidden = "wipe"
vim.bo[state.diff_buf].swapfile = false
-- Create layout: list on left (30 cols), diff on right
vim.cmd("tabnew")
state.diff_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.diff_win, state.diff_buf)
vim.cmd("topleft vsplit")
state.list_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.list_win, state.list_buf)
vim.api.nvim_win_set_width(state.list_win, 35)
-- Window options
for _, win in ipairs({ state.list_win, state.diff_win }) do
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].signcolumn = "no"
vim.wo[win].wrap = false
vim.wo[win].cursorline = true
end
-- Set up keymaps for list buffer
local list_opts = { buffer = state.list_buf, noremap = true, silent = true }
vim.keymap.set("n", "j", M.next, list_opts)
vim.keymap.set("n", "k", M.prev, list_opts)
vim.keymap.set("n", "<Down>", M.next, list_opts)
vim.keymap.set("n", "<Up>", M.prev, list_opts)
vim.keymap.set("n", "<CR>", function() vim.api.nvim_set_current_win(state.diff_win) end, list_opts)
vim.keymap.set("n", "a", M.approve_current, list_opts)
vim.keymap.set("n", "r", M.reject_current, list_opts)
vim.keymap.set("n", "A", M.approve_all, list_opts)
vim.keymap.set("n", "q", M.close, list_opts)
vim.keymap.set("n", "<Esc>", M.close, list_opts)
-- Set up keymaps for diff buffer
local diff_opts = { buffer = state.diff_buf, noremap = true, silent = true }
vim.keymap.set("n", "j", M.next, diff_opts)
vim.keymap.set("n", "k", M.prev, diff_opts)
vim.keymap.set("n", "<Tab>", function() vim.api.nvim_set_current_win(state.list_win) end, diff_opts)
vim.keymap.set("n", "a", M.approve_current, diff_opts)
vim.keymap.set("n", "r", M.reject_current, diff_opts)
vim.keymap.set("n", "A", M.approve_all, diff_opts)
vim.keymap.set("n", "q", M.close, diff_opts)
vim.keymap.set("n", "<Esc>", M.close, diff_opts)
state.is_open = true
state.current_index = 1
-- Initial render
update_file_list()
update_diff_view()
-- Focus list window
vim.api.nvim_set_current_win(state.list_win)
end
--- Close the diff review UI
function M.close()
if not state.is_open then
return
end
-- Close the tab (which closes both windows)
pcall(vim.cmd, "tabclose")
state.list_buf = nil
state.list_win = nil
state.diff_buf = nil
state.diff_win = nil
state.is_open = false
end
--- Check if review UI is open
---@return boolean
function M.is_open()
return state.is_open
end
return M

View File

@@ -0,0 +1,380 @@
---@mod codetyper.agent.logs Real-time logging for agent operations
---
--- Captures and displays the agent's thinking process, token usage, and LLM info.
local M = {}
local params = require("codetyper.params.agents.logs")
---@class LogEntry
---@field timestamp string ISO timestamp
---@field level string "info" | "debug" | "request" | "response" | "tool" | "error"
---@field message string Log message
---@field data? table Optional structured data
---@class LogState
---@field entries LogEntry[] All log entries
---@field listeners table[] Functions to call when new entries are added
---@field total_prompt_tokens number Running total of prompt tokens
---@field total_response_tokens number Running total of response tokens
local state = {
entries = {},
listeners = {},
total_prompt_tokens = 0,
total_response_tokens = 0,
current_provider = nil,
current_model = nil,
}
--- Get current timestamp
---@return string
local function get_timestamp()
return os.date("%H:%M:%S")
end
--- Add a log entry
---@param level string Log level
---@param message string Log message
---@param data? table Optional data
function M.log(level, message, data)
local entry = {
timestamp = get_timestamp(),
level = level,
message = message,
data = data,
}
table.insert(state.entries, entry)
-- Notify all listeners
for _, listener in ipairs(state.listeners) do
pcall(listener, entry)
end
end
--- Log info message
---@param message string
---@param data? table
function M.info(message, data)
M.log("info", message, data)
end
--- Log debug message
---@param message string
---@param data? table
function M.debug(message, data)
M.log("debug", message, data)
end
--- Log API request
---@param provider string LLM provider
---@param model string Model name
---@param prompt_tokens? number Estimated prompt tokens
function M.request(provider, model, prompt_tokens)
state.current_provider = provider
state.current_model = model
local msg = string.format("[%s] %s", provider:upper(), model)
if prompt_tokens then
msg = msg .. string.format(" | Prompt: ~%d tokens", prompt_tokens)
end
M.log("request", msg, {
provider = provider,
model = model,
prompt_tokens = prompt_tokens,
})
end
--- Log API response with token usage
---@param prompt_tokens number Tokens used in prompt
---@param response_tokens number Tokens in response
---@param stop_reason? string Why the response stopped
function M.response(prompt_tokens, response_tokens, stop_reason)
state.total_prompt_tokens = state.total_prompt_tokens + prompt_tokens
state.total_response_tokens = state.total_response_tokens + response_tokens
local msg = string.format(
"Tokens: %d in / %d out | Total: %d in / %d out",
prompt_tokens,
response_tokens,
state.total_prompt_tokens,
state.total_response_tokens
)
if stop_reason then
msg = msg .. " | Stop: " .. stop_reason
end
M.log("response", msg, {
prompt_tokens = prompt_tokens,
response_tokens = response_tokens,
total_prompt = state.total_prompt_tokens,
total_response = state.total_response_tokens,
stop_reason = stop_reason,
})
end
--- Log tool execution
---@param tool_name string Name of the tool
---@param status string "start" | "success" | "error" | "approval"
---@param details? string Additional details
function M.tool(tool_name, status, details)
local icons = params.icons
local msg = string.format("[%s] %s", icons[status] or status, tool_name)
if details then
msg = msg .. ": " .. details
end
M.log("tool", msg, {
tool = tool_name,
status = status,
details = details,
})
end
--- Log error
---@param message string
---@param data? table
function M.error(message, data)
M.log("error", "ERROR: " .. message, data)
end
--- Log warning
---@param message string
---@param data? table
function M.warning(message, data)
M.log("warning", "WARN: " .. message, data)
end
--- Add log entry (compatibility function for scheduler)
--- Accepts {type = "info", message = "..."} format
---@param entry table Log entry with type and message
function M.add(entry)
if entry.type == "clear" then
M.clear()
return
end
M.log(entry.type or "info", entry.message or "", entry.data)
end
--- Log thinking/reasoning step (Claude Code style)
---@param step string Description of what's happening
function M.thinking(step)
M.log("thinking", step)
end
--- Log a reasoning/explanation message (shown prominently)
---@param message string The reasoning message
function M.reason(message)
M.log("reason", message)
end
--- Log file read operation
---@param filepath string Path of file being read
---@param lines? number Number of lines read
function M.read(filepath, lines)
local msg = string.format("Read(%s)", vim.fn.fnamemodify(filepath, ":~:."))
if lines then
msg = msg .. string.format("\n ⎿ Read %d lines", lines)
end
M.log("action", msg)
end
--- Log explore/search operation
---@param description string What we're exploring
function M.explore(description)
M.log("action", string.format("Explore(%s)", description))
end
--- Log explore done
---@param tool_uses number Number of tool uses
---@param tokens number Tokens used
---@param duration number Duration in seconds
function M.explore_done(tool_uses, tokens, duration)
M.log("result", string.format(" ⎿ Done (%d tool uses · %.1fk tokens · %.1fs)", tool_uses, tokens / 1000, duration))
end
--- Log update/edit operation
---@param filepath string Path of file being edited
---@param added? number Lines added
---@param removed? number Lines removed
function M.update(filepath, added, removed)
local msg = string.format("Update(%s)", vim.fn.fnamemodify(filepath, ":~:."))
if added or removed then
local parts = {}
if added and added > 0 then
table.insert(parts, string.format("Added %d lines", added))
end
if removed and removed > 0 then
table.insert(parts, string.format("Removed %d lines", removed))
end
if #parts > 0 then
msg = msg .. "\n" .. table.concat(parts, ", ")
end
end
M.log("action", msg)
end
--- Log a task/step that's in progress
---@param task string Task name
---@param status string Status message (optional)
function M.task(task, status)
local msg = task
if status then
msg = msg .. " " .. status
end
M.log("task", msg)
end
--- Log task completion
---@param next_task? string Next task (optional)
function M.task_done(next_task)
local msg = " ⎿ Done"
if next_task then
msg = msg .. "\n" .. next_task
end
M.log("result", msg)
end
--- Register a listener for new log entries
---@param callback fun(entry: LogEntry)
---@return number Listener ID for removal
function M.add_listener(callback)
table.insert(state.listeners, callback)
return #state.listeners
end
--- Remove a listener
---@param id number Listener ID
function M.remove_listener(id)
if id > 0 and id <= #state.listeners then
table.remove(state.listeners, id)
end
end
--- Get all log entries
---@return LogEntry[]
function M.get_entries()
return state.entries
end
--- Get token totals
---@return number, number prompt_tokens, response_tokens
function M.get_token_totals()
return state.total_prompt_tokens, state.total_response_tokens
end
--- Get current provider info
---@return string?, string? provider, model
function M.get_provider_info()
return state.current_provider, state.current_model
end
--- Clear all logs and reset counters
function M.clear()
state.entries = {}
state.total_prompt_tokens = 0
state.total_response_tokens = 0
state.current_provider = nil
state.current_model = nil
-- Notify listeners of clear
for _, listener in ipairs(state.listeners) do
pcall(listener, { level = "clear" })
end
end
--- Format entry for display
---@param entry LogEntry
---@return string
function M.format_entry(entry)
-- Claude Code style formatting for thinking/action entries
local thinking_types = params.thinking_types
local is_thinking = vim.tbl_contains(thinking_types, entry.level)
if is_thinking then
local prefix = params.thinking_prefixes[entry.level] or ""
if prefix ~= "" then
return prefix .. " " .. entry.message
else
return entry.message
end
end
-- Traditional log format for other types
local level_prefix = params.level_icons[entry.level] or "?"
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
--- Format entry for display in chat (compact Claude Code style)
---@param entry LogEntry
---@return string|nil Formatted string or nil to skip
function M.format_for_chat(entry)
-- Skip certain log types in chat view
local skip_types = { "debug", "queue", "patch" }
if vim.tbl_contains(skip_types, entry.level) then
return nil
end
-- Claude Code style formatting
local thinking_types = params.thinking_types
if vim.tbl_contains(thinking_types, entry.level) then
local prefix = params.thinking_prefixes[entry.level] or ""
if prefix ~= "" then
return prefix .. " " .. entry.message
else
return entry.message
end
end
-- Tool logs
if entry.level == "tool" then
return "" .. entry.message:gsub("^%[.-%] ", "")
end
-- Info/success
if entry.level == "info" or entry.level == "success" then
return "" .. entry.message
end
-- Errors
if entry.level == "error" then
return "" .. entry.message
end
-- Request/response (compact)
if entry.level == "request" then
return "" .. entry.message
end
if entry.level == "response" then
return "" .. entry.message
end
return nil
end
--- Estimate token count for a string (rough approximation)
---@param text string
---@return number
function M.estimate_tokens(text)
-- Rough estimate: ~4 characters per token for English text
return math.ceil(#text / 4)
end
return M

View File

@@ -0,0 +1,382 @@
---@mod codetyper.logs_panel Standalone logs panel for code generation
---
--- Shows real-time logs when generating code via /@ @/ prompts.
local M = {}
local logs = require("codetyper.adapters.nvim.ui.logs")
local queue = require("codetyper.core.events.queue")
---@class LogsPanelState
---@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 dimensions
local LOGS_WIDTH = 60
local QUEUE_HEIGHT = 8
--- Add a log entry to the buffer
---@param entry table Log entry
local function add_log_entry(entry)
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
vim.schedule(function()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
-- Handle clear event
if entry.level == "clear" then
vim.bo[state.buf].modifiable = true
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
"Generation Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.buf].modifiable = false
return
end
vim.bo[state.buf].modifiable = true
local formatted = logs.format_entry(entry)
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_lines)
-- Apply highlighting based on level
local hl_map = {
info = "DiagnosticInfo",
debug = "Comment",
request = "DiagnosticWarn",
response = "DiagnosticOk",
tool = "DiagnosticHint",
error = "DiagnosticError",
}
local hl = hl_map[entry.level] or "Normal"
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
-- Auto-scroll logs
if state.win and vim.api.nvim_win_is_valid(state.win) then
local new_count = vim.api.nvim_buf_line_count(state.buf)
pcall(vim.api.nvim_win_set_cursor, state.win, { new_count, 0 })
end
end)
end
--- Update the title with token counts
local function update_title()
if not state.win or not vim.api.nvim_win_is_valid(state.win) then
return
end
local prompt_tokens, response_tokens = logs.get_token_totals()
local provider, model = logs.get_provider_info()
if provider and state.buf and vim.api.nvim_buf_is_valid(state.buf) then
vim.bo[state.buf].modifiable = true
local title = string.format("%s | %d/%d tokens", (provider or ""):upper(), prompt_tokens, response_tokens)
vim.api.nvim_buf_set_lines(state.buf, 0, 1, false, { title })
vim.bo[state.buf].modifiable = false
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
return
end
-- Clear previous logs
logs.clear()
-- Create logs buffer
state.buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.buf].buftype = "nofile"
vim.bo[state.buf].bufhidden = "hide"
vim.bo[state.buf].swapfile = false
-- Create window on the right
vim.cmd("botright vsplit")
state.win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.win, state.buf)
vim.api.nvim_win_set_width(state.win, LOGS_WIDTH)
-- Window options for logs
vim.wo[state.win].number = false
vim.wo[state.win].relativenumber = false
vim.wo[state.win].signcolumn = "no"
vim.wo[state.win].wrap = true
vim.wo[state.win].linebreak = true
vim.wo[state.win].winfixwidth = true
vim.wo[state.win].cursorline = false
-- Set initial content for logs
vim.bo[state.buf].modifiable = true
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
"Generation Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.buf].modifiable = false
-- 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)
if entry.level == "response" then
vim.schedule(update_title)
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
vim.cmd("wincmd p")
logs.info("Logs panel opened")
end
--- Close the logs panel
---@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
pcall(logs.remove_listener, state.listener_id)
state.listener_id = nil
end
-- 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
state.is_open = false
end
--- Toggle the logs panel
function M.toggle()
if state.is_open then
M.close()
else
M.open()
end
end
--- Check if panel is open
---@return boolean
function M.is_open()
return state.is_open
end
--- Ensure panel is open (call before starting generation)
function M.ensure_open()
if not state.is_open then
M.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

View File

@@ -0,0 +1,44 @@
---@mod codetyper.chat_switcher Modal picker to switch between Ask and Agent modes
local M = {}
--- Show modal to switch between chat modes
function M.show()
local items = {
{ label = "Ask", desc = "Q&A mode - ask questions about code", mode = "ask" },
{ label = "Agent", desc = "Agent mode - can read/edit files", mode = "agent" },
}
vim.ui.select(items, {
prompt = "Select Chat Mode:",
format_item = function(item)
return item.label .. " - " .. item.desc
end,
}, function(choice)
if not choice then
return
end
-- Close current panel first
local ask = require("codetyper.features.ask.engine")
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
if ask.is_open() then
ask.close()
end
if agent_ui.is_open() then
agent_ui.close()
end
-- Open selected mode
vim.schedule(function()
if choice.mode == "ask" then
ask.open()
elseif choice.mode == "agent" then
agent_ui.open()
end
end)
end)
end
return M

View File

@@ -1,4 +1,4 @@
---@mod codetyper.credentials Secure credential storage for Codetyper.nvim
---@mod codetyper.config.credentials Secure credential storage for Codetyper.nvim
---@brief [[
--- Manages API keys and model preferences stored outside of config files.
--- Credentials are stored in ~/.local/share/nvim/codetyper/configuration.json
@@ -512,12 +512,10 @@ function M.show_status()
end
local model_info = p.model and (" - " .. p.model) or ""
table.insert(lines, string.format(" %s %s%s%s%s",
status_icon,
p.name:upper(),
active_marker,
source_info,
model_info))
table.insert(
lines,
string.format(" %s %s%s%s%s", status_icon, p.name:upper(), active_marker, source_info, model_info)
)
end
table.insert(lines, "")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
---@mod codetyper.agent.diff Diff preview UI for agent changes
---
--- Shows diff previews for file changes and bash command approvals.
local M = {}
--- Show a diff preview for file changes
---@param diff_data table { path: string, original: string, modified: string, operation: string }
---@param callback fun(approved: boolean) Called with user decision
function M.show_diff(diff_data, callback)
local original_lines = vim.split(diff_data.original, "\n", { plain = true })
local modified_lines
-- For delete operations, show a clear message
if diff_data.operation == "delete" then
modified_lines = {
"",
" FILE WILL BE DELETED",
"",
" Reason: " .. (diff_data.reason or "No reason provided"),
"",
}
else
modified_lines = vim.split(diff_data.modified, "\n", { plain = true })
end
-- Calculate window dimensions
local width = math.floor(vim.o.columns * 0.8)
local height = math.floor(vim.o.lines * 0.7)
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
-- Create left buffer (original)
local left_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, original_lines)
vim.bo[left_buf].modifiable = false
vim.bo[left_buf].bufhidden = "wipe"
-- Create right buffer (modified)
local right_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, modified_lines)
vim.bo[right_buf].modifiable = false
vim.bo[right_buf].bufhidden = "wipe"
-- Set filetype for syntax highlighting based on file extension
local ext = vim.fn.fnamemodify(diff_data.path, ":e")
if ext and ext ~= "" then
vim.bo[left_buf].filetype = ext
vim.bo[right_buf].filetype = ext
end
-- Create left window (original)
local half_width = math.floor((width - 1) / 2)
local left_win = vim.api.nvim_open_win(left_buf, true, {
relative = "editor",
width = half_width,
height = height - 2,
row = row,
col = col,
style = "minimal",
border = "rounded",
title = " ORIGINAL ",
title_pos = "center",
})
-- Create right window (modified)
local right_win = vim.api.nvim_open_win(right_buf, false, {
relative = "editor",
width = half_width,
height = height - 2,
row = row,
col = col + half_width + 1,
style = "minimal",
border = "rounded",
title = diff_data.operation == "delete" and " ⚠️ DELETE " or (" MODIFIED [" .. diff_data.operation .. "] "),
title_pos = "center",
})
-- Enable diff mode in both windows
vim.api.nvim_win_call(left_win, function()
vim.cmd("diffthis")
end)
vim.api.nvim_win_call(right_win, function()
vim.cmd("diffthis")
end)
-- Sync scrolling
vim.wo[left_win].scrollbind = true
vim.wo[right_win].scrollbind = true
vim.wo[left_win].cursorbind = true
vim.wo[right_win].cursorbind = true
-- Track if callback was already called
local callback_called = false
-- Close function
local function close_and_respond(approved)
if callback_called then
return
end
callback_called = true
-- Disable diff mode
pcall(function()
vim.api.nvim_win_call(left_win, function()
vim.cmd("diffoff")
end)
end)
pcall(function()
vim.api.nvim_win_call(right_win, function()
vim.cmd("diffoff")
end)
end)
-- Close windows
pcall(vim.api.nvim_win_close, left_win, true)
pcall(vim.api.nvim_win_close, right_win, true)
-- Call callback
vim.schedule(function()
callback(approved)
end)
end
-- Set up keymaps for both buffers
local keymap_opts = { noremap = true, silent = true, nowait = true }
for _, buf in ipairs({ left_buf, right_buf }) do
-- Approve
vim.keymap.set("n", "y", function()
close_and_respond(true)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
vim.keymap.set("n", "<CR>", function()
close_and_respond(true)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
-- Reject
vim.keymap.set("n", "n", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
vim.keymap.set("n", "q", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
vim.keymap.set("n", "<Esc>", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
-- Switch between windows
vim.keymap.set("n", "<Tab>", function()
local current = vim.api.nvim_get_current_win()
if current == left_win then
vim.api.nvim_set_current_win(right_win)
else
vim.api.nvim_set_current_win(left_win)
end
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
end
-- Show help message
local help_msg = require("codetyper.prompts.agents.diff").diff_help
-- Iterate to replace {path} variable
local final_help = {}
for _, item in ipairs(help_msg) do
if item[1] == "{path}" then
table.insert(final_help, { diff_data.path, item[2] })
else
table.insert(final_help, item)
end
end
vim.api.nvim_echo(final_help, false, {})
end
---@alias BashApprovalResult {approved: boolean, permission_level: string|nil}
--- Show approval dialog for bash commands with permission levels
---@param command string The bash command to approve
---@param callback fun(result: BashApprovalResult) Called with user decision
function M.show_bash_approval(command, callback)
local permissions = require("codetyper.features.agents.permissions")
-- Check if command is auto-approved
local perm_result = permissions.check_bash_permission(command)
if perm_result.auto and perm_result.allowed then
vim.schedule(function()
callback({ approved = true, permission_level = "auto" })
end)
return
end
-- Create approval dialog with options
local approval_prompts = require("codetyper.prompts.agents.diff").bash_approval
local lines = {
"",
approval_prompts.title,
approval_prompts.divider,
"",
approval_prompts.command_label,
" $ " .. command,
"",
}
-- Add warning for dangerous commands
if not perm_result.allowed and perm_result.reason ~= "Requires approval" then
table.insert(lines, approval_prompts.warning_prefix .. perm_result.reason)
table.insert(lines, "")
end
table.insert(lines, approval_prompts.divider)
table.insert(lines, "")
for _, opt in ipairs(approval_prompts.options) do
table.insert(lines, opt)
end
table.insert(lines, "")
table.insert(lines, approval_prompts.divider)
table.insert(lines, approval_prompts.cancel_hint)
table.insert(lines, "")
local width = math.max(65, #command + 15)
local height = #lines
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = "wipe"
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = width,
height = height,
row = math.floor((vim.o.lines - height) / 2),
col = math.floor((vim.o.columns - width) / 2),
style = "minimal",
border = "rounded",
title = " Approve Command? ",
title_pos = "center",
})
-- Apply highlighting
vim.api.nvim_buf_add_highlight(buf, -1, "Title", 1, 0, -1)
vim.api.nvim_buf_add_highlight(buf, -1, "String", 5, 0, -1)
-- Highlight options
for i, line in ipairs(lines) do
if line:match("^%s+%[y%]") then
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticOk", i - 1, 0, -1)
elseif line:match("^%s+%[s%]") then
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticInfo", i - 1, 0, -1)
elseif line:match("^%s+%[a%]") then
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticHint", i - 1, 0, -1)
elseif line:match("^%s+%[n%]") then
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticError", i - 1, 0, -1)
elseif line:match("⚠️") then
vim.api.nvim_buf_add_highlight(buf, -1, "DiagnosticWarn", i - 1, 0, -1)
end
end
local callback_called = false
local function close_and_respond(approved, permission_level)
if callback_called then
return
end
callback_called = true
-- Grant permission if approved with session or list level
if approved and permission_level then
permissions.grant_permission(command, permission_level)
end
pcall(vim.api.nvim_win_close, win, true)
vim.schedule(function()
callback({ approved = approved, permission_level = permission_level })
end)
end
local keymap_opts = { buffer = buf, noremap = true, silent = true, nowait = true }
-- Allow once
vim.keymap.set("n", "y", function()
close_and_respond(true, "allow")
end, keymap_opts)
vim.keymap.set("n", "<CR>", function()
close_and_respond(true, "allow")
end, keymap_opts)
-- Allow this session
vim.keymap.set("n", "s", function()
close_and_respond(true, "allow_session")
end, keymap_opts)
-- Add to allow list
vim.keymap.set("n", "a", function()
close_and_respond(true, "allow_list")
end, keymap_opts)
-- Reject
vim.keymap.set("n", "n", function()
close_and_respond(false, nil)
end, keymap_opts)
vim.keymap.set("n", "q", function()
close_and_respond(false, nil)
end, keymap_opts)
vim.keymap.set("n", "<Esc>", function()
close_and_respond(false, nil)
end, keymap_opts)
end
--- Show approval dialog for bash commands (simple version for backward compatibility)
---@param command string The bash command to approve
---@param callback fun(approved: boolean) Called with user decision
function M.show_bash_approval_simple(command, callback)
M.show_bash_approval(command, function(result)
callback(result.approved)
end)
end
return M

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,572 @@
---@mod codetyper.agent.search_replace Search/Replace editing system
---@brief [[
--- Implements SEARCH/REPLACE block parsing and fuzzy matching for reliable code edits.
--- Parses and applies SEARCH/REPLACE blocks from LLM responses.
---@brief ]]
local M = {}
local params = require("codetyper.params.agents.search_replace").patterns
---@class SearchReplaceBlock
---@field search string The text to search for
---@field replace string The text to replace with
---@field file_path string|nil Optional file path for multi-file edits
---@class MatchResult
---@field start_line number 1-indexed start line
---@field end_line number 1-indexed end line
---@field start_col number 1-indexed start column (for partial line matches)
---@field end_col number 1-indexed end column
---@field strategy string Which matching strategy succeeded
---@field confidence number Match confidence (0.0-1.0)
--- Parse SEARCH/REPLACE blocks from LLM response
--- Supports multiple formats:
--- Format 1 (dash style):
--- ------- SEARCH
--- old code
--- =======
--- new code
--- +++++++ REPLACE
---
--- Format 2 (claude style):
--- <<<<<<< SEARCH
--- old code
--- =======
--- new code
--- >>>>>>> REPLACE
---
--- Format 3 (simple):
--- [SEARCH]
--- old code
--- [REPLACE]
--- new code
--- [END]
---
---@param response string LLM response text
---@return SearchReplaceBlock[]
function M.parse_blocks(response)
local blocks = {}
-- Try dash-style format: ------- SEARCH ... ======= ... +++++++ REPLACE
for search, replace in response:gmatch(params.dash_style) do
table.insert(blocks, { search = search, replace = replace })
end
if #blocks > 0 then
return blocks
end
-- Try claude-style format: <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE
for search, replace in response:gmatch(params.claude_style) do
table.insert(blocks, { search = search, replace = replace })
end
if #blocks > 0 then
return blocks
end
-- Try simple format: [SEARCH] ... [REPLACE] ... [END]
for search, replace in response:gmatch(params.simple_style) do
table.insert(blocks, { search = search, replace = replace })
end
if #blocks > 0 then
return blocks
end
-- Try markdown diff format: ```diff ... ```
local diff_block = response:match(params.diff_block)
if diff_block then
local old_lines = {}
local new_lines = {}
for line in diff_block:gmatch("[^\n]+") do
if line:match("^%-[^%-]") then
-- Removed line (starts with single -)
table.insert(old_lines, line:sub(2))
elseif line:match("^%+[^%+]") then
-- Added line (starts with single +)
table.insert(new_lines, line:sub(2))
elseif line:match("^%s") or line:match("^[^%-%+@]") then
-- Context line
table.insert(old_lines, line:match("^%s?(.*)"))
table.insert(new_lines, line:match("^%s?(.*)"))
end
end
if #old_lines > 0 or #new_lines > 0 then
table.insert(blocks, {
search = table.concat(old_lines, "\n"),
replace = table.concat(new_lines, "\n"),
})
end
end
return blocks
end
--- Get indentation of a line
---@param line string
---@return string
local function get_indentation(line)
if not line then
return ""
end
return line:match("^(%s*)") or ""
end
--- Normalize whitespace in a string (collapse multiple spaces to one)
---@param str string
---@return string
local function normalize_whitespace(str)
-- Wrap in parentheses to only return first value (gsub returns string + count)
return (str:gsub("%s+", " "):gsub("^%s*", ""):gsub("%s*$", ""))
end
--- Trim trailing whitespace from each line
---@param str string
---@return string
local function trim_lines(str)
local lines = vim.split(str, "\n", { plain = true })
for i, line in ipairs(lines) do
-- Wrap in parentheses to only get string, not count
lines[i] = (line:gsub("%s+$", ""))
end
return table.concat(lines, "\n")
end
--- Calculate Levenshtein distance between two strings
---@param s1 string
---@param s2 string
---@return number
local function levenshtein(s1, s2)
local len1, len2 = #s1, #s2
if len1 == 0 then
return len2
end
if len2 == 0 then
return len1
end
local matrix = {}
for i = 0, len1 do
matrix[i] = { [0] = i }
end
for j = 0, len2 do
matrix[0][j] = j
end
for i = 1, len1 do
for j = 1, len2 do
local cost = (s1:sub(i, i) == s2:sub(j, j)) and 0 or 1
matrix[i][j] = math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
)
end
end
return matrix[len1][len2]
end
--- Calculate similarity ratio (0.0-1.0) between two strings
---@param s1 string
---@param s2 string
---@return number
local function similarity(s1, s2)
if s1 == s2 then
return 1.0
end
local max_len = math.max(#s1, #s2)
if max_len == 0 then
return 1.0
end
local distance = levenshtein(s1, s2)
return 1.0 - (distance / max_len)
end
--- Strategy 1: Exact match
---@param content_lines string[]
---@param search_lines string[]
---@return MatchResult|nil
local function exact_match(content_lines, search_lines)
if #search_lines == 0 then
return nil
end
for i = 1, #content_lines - #search_lines + 1 do
local match = true
for j = 1, #search_lines do
if content_lines[i + j - 1] ~= search_lines[j] then
match = false
break
end
end
if match then
return {
start_line = i,
end_line = i + #search_lines - 1,
start_col = 1,
end_col = #content_lines[i + #search_lines - 1],
strategy = "exact",
confidence = 1.0,
}
end
end
return nil
end
--- Strategy 2: Line-trimmed match (ignore trailing whitespace)
---@param content_lines string[]
---@param search_lines string[]
---@return MatchResult|nil
local function line_trimmed_match(content_lines, search_lines)
if #search_lines == 0 then
return nil
end
local trimmed_search = {}
for _, line in ipairs(search_lines) do
table.insert(trimmed_search, (line:gsub("%s+$", "")))
end
for i = 1, #content_lines - #search_lines + 1 do
local match = true
for j = 1, #search_lines do
local trimmed_content = content_lines[i + j - 1]:gsub("%s+$", "")
if trimmed_content ~= trimmed_search[j] then
match = false
break
end
end
if match then
return {
start_line = i,
end_line = i + #search_lines - 1,
start_col = 1,
end_col = #content_lines[i + #search_lines - 1],
strategy = "line_trimmed",
confidence = 0.95,
}
end
end
return nil
end
--- Strategy 3: Indentation-flexible match (normalize indentation)
---@param content_lines string[]
---@param search_lines string[]
---@return MatchResult|nil
local function indentation_flexible_match(content_lines, search_lines)
if #search_lines == 0 then
return nil
end
-- Get base indentation from search (first non-empty line)
local search_indent = ""
for _, line in ipairs(search_lines) do
if line:match("%S") then
search_indent = get_indentation(line)
break
end
end
-- Strip common indentation from search
local stripped_search = {}
for _, line in ipairs(search_lines) do
if line:match("^" .. vim.pesc(search_indent)) then
table.insert(stripped_search, line:sub(#search_indent + 1))
else
table.insert(stripped_search, line)
end
end
for i = 1, #content_lines - #search_lines + 1 do
-- Get content indentation at this position
local content_indent = ""
for j = 0, #search_lines - 1 do
local line = content_lines[i + j]
if line:match("%S") then
content_indent = get_indentation(line)
break
end
end
local match = true
for j = 1, #search_lines do
local content_line = content_lines[i + j - 1]
local expected = content_indent .. stripped_search[j]
-- Compare with normalized indentation
if content_line:gsub("%s+$", "") ~= expected:gsub("%s+$", "") then
match = false
break
end
end
if match then
return {
start_line = i,
end_line = i + #search_lines - 1,
start_col = 1,
end_col = #content_lines[i + #search_lines - 1],
strategy = "indentation_flexible",
confidence = 0.9,
}
end
end
return nil
end
--- Strategy 4: Block anchor match (match first/last lines, fuzzy middle)
---@param content_lines string[]
---@param search_lines string[]
---@return MatchResult|nil
local function block_anchor_match(content_lines, search_lines)
if #search_lines < 2 then
return nil
end
local first_search = search_lines[1]:gsub("%s+$", "")
local last_search = search_lines[#search_lines]:gsub("%s+$", "")
-- Find potential start positions
local candidates = {}
for i = 1, #content_lines - #search_lines + 1 do
local first_content = content_lines[i]:gsub("%s+$", "")
if similarity(first_content, first_search) > 0.8 then
-- Check if last line also matches
local last_idx = i + #search_lines - 1
if last_idx <= #content_lines then
local last_content = content_lines[last_idx]:gsub("%s+$", "")
if similarity(last_content, last_search) > 0.8 then
-- Calculate overall similarity
local total_sim = 0
for j = 1, #search_lines do
local c = content_lines[i + j - 1]:gsub("%s+$", "")
local s = search_lines[j]:gsub("%s+$", "")
total_sim = total_sim + similarity(c, s)
end
local avg_sim = total_sim / #search_lines
if avg_sim > 0.7 then
table.insert(candidates, { start = i, similarity = avg_sim })
end
end
end
end
end
-- Return best match
if #candidates > 0 then
table.sort(candidates, function(a, b)
return a.similarity > b.similarity
end)
local best = candidates[1]
return {
start_line = best.start,
end_line = best.start + #search_lines - 1,
start_col = 1,
end_col = #content_lines[best.start + #search_lines - 1],
strategy = "block_anchor",
confidence = best.similarity * 0.85,
}
end
return nil
end
--- Strategy 5: Whitespace-normalized match
---@param content_lines string[]
---@param search_lines string[]
---@return MatchResult|nil
local function whitespace_normalized_match(content_lines, search_lines)
if #search_lines == 0 then
return nil
end
-- Normalize search lines
local norm_search = {}
for _, line in ipairs(search_lines) do
table.insert(norm_search, normalize_whitespace(line))
end
for i = 1, #content_lines - #search_lines + 1 do
local match = true
for j = 1, #search_lines do
local norm_content = normalize_whitespace(content_lines[i + j - 1])
if norm_content ~= norm_search[j] then
match = false
break
end
end
if match then
return {
start_line = i,
end_line = i + #search_lines - 1,
start_col = 1,
end_col = #content_lines[i + #search_lines - 1],
strategy = "whitespace_normalized",
confidence = 0.8,
}
end
end
return nil
end
--- Find the best match for search text in content
---@param content string File content
---@param search string Text to search for
---@return MatchResult|nil
function M.find_match(content, search)
local content_lines = vim.split(content, "\n", { plain = true })
local search_lines = vim.split(search, "\n", { plain = true })
-- Remove trailing empty lines from search
while #search_lines > 0 and search_lines[#search_lines]:match("^%s*$") do
table.remove(search_lines)
end
if #search_lines == 0 then
return nil
end
-- Try strategies in order of strictness
local strategies = {
exact_match,
line_trimmed_match,
indentation_flexible_match,
block_anchor_match,
whitespace_normalized_match,
}
for _, strategy in ipairs(strategies) do
local result = strategy(content_lines, search_lines)
if result then
return result
end
end
return nil
end
--- Apply a single SEARCH/REPLACE block to content
---@param content string Original file content
---@param block SearchReplaceBlock
---@return string|nil new_content
---@return MatchResult|nil match_info
---@return string|nil error
function M.apply_block(content, block)
local match = M.find_match(content, block.search)
if not match then
return nil, nil, "Could not find search text in file"
end
local content_lines = vim.split(content, "\n", { plain = true })
local replace_lines = vim.split(block.replace, "\n", { plain = true })
-- Adjust indentation of replacement to match original
local original_indent = get_indentation(content_lines[match.start_line])
local replace_indent = ""
for _, line in ipairs(replace_lines) do
if line:match("%S") then
replace_indent = get_indentation(line)
break
end
end
-- Apply indentation adjustment
local adjusted_replace = {}
for _, line in ipairs(replace_lines) do
if line:match("^" .. vim.pesc(replace_indent)) then
table.insert(adjusted_replace, original_indent .. line:sub(#replace_indent + 1))
elseif line:match("^%s*$") then
table.insert(adjusted_replace, "")
else
table.insert(adjusted_replace, original_indent .. line)
end
end
-- Build new content
local new_lines = {}
for i = 1, match.start_line - 1 do
table.insert(new_lines, content_lines[i])
end
for _, line in ipairs(adjusted_replace) do
table.insert(new_lines, line)
end
for i = match.end_line + 1, #content_lines do
table.insert(new_lines, content_lines[i])
end
return table.concat(new_lines, "\n"), match, nil
end
--- Apply multiple SEARCH/REPLACE blocks to content
---@param content string Original file content
---@param blocks SearchReplaceBlock[]
---@return string new_content
---@return table results Array of {success: boolean, match: MatchResult|nil, error: string|nil}
function M.apply_blocks(content, blocks)
local current_content = content
local results = {}
for _, block in ipairs(blocks) do
local new_content, match, err = M.apply_block(current_content, block)
if new_content then
current_content = new_content
table.insert(results, { success = true, match = match })
else
table.insert(results, { success = false, error = err })
end
end
return current_content, results
end
--- Apply SEARCH/REPLACE blocks to a buffer
---@param bufnr number Buffer number
---@param blocks SearchReplaceBlock[]
---@return boolean success
---@return string|nil error
function M.apply_to_buffer(bufnr, blocks)
if not vim.api.nvim_buf_is_valid(bufnr) then
return false, "Invalid buffer"
end
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
local new_content, results = M.apply_blocks(content, blocks)
-- Check for any failures
local failures = {}
for i, result in ipairs(results) do
if not result.success then
table.insert(failures, string.format("Block %d: %s", i, result.error or "unknown error"))
end
end
if #failures > 0 then
return false, table.concat(failures, "; ")
end
-- Apply to buffer
local new_lines = vim.split(new_content, "\n", { plain = true })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines)
return true, nil
end
--- Check if response contains SEARCH/REPLACE blocks
---@param response string
---@return boolean
function M.has_blocks(response)
return #M.parse_blocks(response) > 0
end
return M

View File

@@ -0,0 +1,117 @@
---@mod codetyper.agent.intent Intent detection from prompts
---@brief [[
--- Parses prompt content to determine user intent and target scope.
--- Intents determine how the generated code should be applied.
---@brief ]]
local M = {}
---@class Intent
---@field type string "complete"|"refactor"|"add"|"fix"|"document"|"test"|"explain"|"optimize"
---@field scope_hint string|nil "function"|"class"|"block"|"file"|"selection"|nil
---@field confidence number 0.0-1.0 how confident we are about the intent
---@field action string "replace"|"insert"|"append"|"none"
---@field keywords string[] Keywords that triggered this intent
local params = require("codetyper.params.agents.intent")
local intent_patterns = params.intent_patterns
local scope_patterns = params.scope_patterns
local prompts = require("codetyper.prompts.agents.intent")
--- Detect intent from prompt content
---@param prompt string The prompt content
---@return Intent
function M.detect(prompt)
local lower = prompt:lower()
local best_match = nil
local best_priority = 999
local matched_keywords = {}
-- Check each intent type
for intent_type, config in pairs(intent_patterns) do
for _, pattern in ipairs(config.patterns) do
if lower:find(pattern, 1, true) then
if config.priority < best_priority then
best_match = intent_type
best_priority = config.priority
matched_keywords = { pattern }
elseif config.priority == best_priority and best_match == intent_type then
table.insert(matched_keywords, pattern)
end
end
end
end
-- Default to "add" if no clear intent
if not best_match then
best_match = "add"
matched_keywords = {}
end
local config = intent_patterns[best_match]
-- Detect scope hint from prompt
local scope_hint = config.scope_hint
for pattern, hint in pairs(scope_patterns) do
if lower:find(pattern, 1, true) then
scope_hint = hint or scope_hint
break
end
end
-- Calculate confidence based on keyword matches
local confidence = 0.5 + (#matched_keywords * 0.15)
confidence = math.min(confidence, 1.0)
return {
type = best_match,
scope_hint = scope_hint,
confidence = confidence,
action = config.action,
keywords = matched_keywords,
}
end
--- Check if intent requires code modification
---@param intent Intent
---@return boolean
function M.modifies_code(intent)
return intent.action ~= "none"
end
--- Check if intent should replace existing code
---@param intent Intent
---@return boolean
function M.is_replacement(intent)
return intent.action == "replace"
end
--- Check if intent adds new code
---@param intent Intent
---@return boolean
function M.is_insertion(intent)
return intent.action == "insert" or intent.action == "append"
end
--- Get system prompt modifier based on intent
---@param intent Intent
---@return string
function M.get_prompt_modifier(intent)
local modifiers = prompts.modifiers
return modifiers[intent.type] or modifiers.add
end
--- Format intent for logging
---@param intent Intent
---@return string
function M.format(intent)
return string.format(
"%s (scope: %s, action: %s, confidence: %.2f)",
intent.type,
intent.scope_hint or "auto",
intent.action,
intent.confidence
)
end
return M

View File

@@ -0,0 +1,275 @@
---@mod codetyper.agent.confidence Response confidence scoring
---@brief [[
--- Scores LLM responses using heuristics to decide if escalation is needed.
--- Returns 0.0-1.0 where higher = more confident the response is good.
---@brief ]]
local M = {}
local params = require("codetyper.params.agents.confidence")
--- Heuristic weights (must sum to 1.0)
M.weights = params.weights
--- Uncertainty phrases that indicate low confidence
local uncertainty_phrases = params.uncertainty_phrases
--- Score based on response length relative to prompt
---@param response string
---@param prompt string
---@return number 0.0-1.0
local function score_length(response, prompt)
local response_len = #response
local prompt_len = #prompt
-- Very short response to long prompt is suspicious
if prompt_len > 50 and response_len < 20 then
return 0.2
end
-- Response should generally be longer than prompt for code generation
local ratio = response_len / math.max(prompt_len, 1)
if ratio < 0.5 then
return 0.3
elseif ratio < 1.0 then
return 0.6
elseif ratio < 2.0 then
return 0.8
else
return 1.0
end
end
--- Score based on uncertainty phrases
---@param response string
---@return number 0.0-1.0
local function score_uncertainty(response)
local lower = response:lower()
local found = 0
for _, phrase in ipairs(uncertainty_phrases) do
if lower:find(phrase:lower(), 1, true) then
found = found + 1
end
end
-- More uncertainty phrases = lower score
if found == 0 then
return 1.0
elseif found == 1 then
return 0.7
elseif found == 2 then
return 0.5
else
return 0.2
end
end
--- Score based on syntax completeness
---@param response string
---@return number 0.0-1.0
local function score_syntax(response)
local score = 1.0
-- Check bracket balance
if not require("codetyper.support.utils").check_brackets(response) then
score = score - 0.4
end
-- Check for common incomplete patterns
-- Lua: unbalanced end/function
local function_count = select(2, response:gsub("function%s*%(", ""))
+ select(2, response:gsub("function%s+%w+%(", ""))
local end_count = select(2, response:gsub("%f[%w]end%f[%W]", ""))
if function_count > end_count + 2 then
score = score - 0.2
end
-- JavaScript/TypeScript: unclosed template literals
local backtick_count = select(2, response:gsub("`", ""))
if backtick_count % 2 ~= 0 then
score = score - 0.2
end
-- String quotes balance
local double_quotes = select(2, response:gsub('"', ""))
local single_quotes = select(2, response:gsub("'", ""))
-- Allow for escaped quotes by being lenient
if double_quotes % 2 ~= 0 and not response:find('\\"') then
score = score - 0.1
end
if single_quotes % 2 ~= 0 and not response:find("\\'") then
score = score - 0.1
end
return math.max(0, score)
end
--- Score based on line repetition
---@param response string
---@return number 0.0-1.0
local function score_repetition(response)
local lines = vim.split(response, "\n", { plain = true })
if #lines < 3 then
return 1.0
end
-- Count duplicate non-empty lines
local seen = {}
local duplicates = 0
for _, line in ipairs(lines) do
local trimmed = vim.trim(line)
if #trimmed > 10 then -- Only check substantial lines
if seen[trimmed] then
duplicates = duplicates + 1
end
seen[trimmed] = true
end
end
local dup_ratio = duplicates / #lines
if dup_ratio < 0.1 then
return 1.0
elseif dup_ratio < 0.2 then
return 0.8
elseif dup_ratio < 0.3 then
return 0.5
else
return 0.2 -- High repetition = degraded output
end
end
--- Score based on truncation indicators
---@param response string
---@return number 0.0-1.0
local function score_truncation(response)
local score = 1.0
-- Ends with ellipsis
if response:match("%.%.%.$") then
score = score - 0.5
end
-- Ends with incomplete comment
if response:match("/%*[^*/]*$") then -- Unclosed /* comment
score = score - 0.4
end
if response:match("<!%-%-[^>]*$") then -- Unclosed HTML comment
score = score - 0.4
end
-- Ends mid-statement (common patterns)
local trimmed = vim.trim(response)
local last_char = trimmed:sub(-1)
-- Suspicious endings
if last_char == "=" or last_char == "," or last_char == "(" then
score = score - 0.3
end
-- Very short last line after long response
local lines = vim.split(response, "\n", { plain = true })
if #lines > 5 then
local last_line = vim.trim(lines[#lines])
if #last_line < 5 and not last_line:match("^[%}%]%)%;end]") then
score = score - 0.2
end
end
return math.max(0, score)
end
---@class ConfidenceBreakdown
---@field length number
---@field uncertainty number
---@field syntax number
---@field repetition number
---@field truncation number
---@field weighted_total number
--- Calculate confidence score for response
---@param response string The LLM response
---@param prompt string The original prompt
---@param context table|nil Additional context (unused for now)
---@return number confidence 0.0-1.0
---@return ConfidenceBreakdown breakdown Individual scores
function M.score(response, prompt, context)
_ = context -- Reserved for future use
if not response or #response == 0 then
return 0,
{
length = 0,
uncertainty = 0,
syntax = 0,
repetition = 0,
truncation = 0,
weighted_total = 0,
}
end
local scores = {
length = score_length(response, prompt or ""),
uncertainty = score_uncertainty(response),
syntax = score_syntax(response),
repetition = score_repetition(response),
truncation = score_truncation(response),
}
-- Calculate weighted total
local weighted = 0
for key, weight in pairs(M.weights) do
weighted = weighted + (scores[key] * weight)
end
scores.weighted_total = weighted
return weighted, scores
end
--- Check if response needs escalation
---@param confidence number
---@param threshold number|nil Default: 0.7
---@return boolean needs_escalation
function M.needs_escalation(confidence, threshold)
threshold = threshold or 0.7
return confidence < threshold
end
--- Get human-readable confidence level
---@param confidence number
---@return string
function M.level_name(confidence)
if confidence >= 0.9 then
return "excellent"
elseif confidence >= 0.8 then
return "good"
elseif confidence >= 0.7 then
return "acceptable"
elseif confidence >= 0.5 then
return "uncertain"
else
return "poor"
end
end
--- Format breakdown for logging
---@param breakdown ConfidenceBreakdown
---@return string
function M.format_breakdown(breakdown)
return string.format(
"len:%.2f unc:%.2f syn:%.2f rep:%.2f tru:%.2f = %.2f",
breakdown.length,
breakdown.uncertainty,
breakdown.syntax,
breakdown.repetition,
breakdown.truncation,
breakdown.weighted_total
)
end
return M

View File

@@ -100,7 +100,7 @@ end
---@return string Model name
local function get_model()
-- Priority: stored credentials > config
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_model = credentials.get_model("copilot")
if stored_model then
return stored_model
@@ -224,8 +224,7 @@ end
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil)
local function make_request(token, body, callback)
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com")
.. "/chat/completions"
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com") .. "/chat/completions"
local json_body = vim.json.encode(body)
local headers = build_headers(token)
@@ -280,7 +279,10 @@ local function make_request(token, body, callback)
if response.error then
local error_msg = response.error.message or "Copilot API error"
if response.error.code == "rate_limit_exceeded" or (error_msg:match("limit") and error_msg:match("plan")) then
if
response.error.code == "rate_limit_exceeded"
or (error_msg:match("limit") and error_msg:match("plan"))
then
error_msg = "Copilot rate limit: " .. error_msg
M.suggest_ollama_fallback(error_msg)
end

View File

@@ -12,7 +12,7 @@ local API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
---@return string|nil API key
local function get_api_key()
-- Priority: stored credentials > config > environment
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_key = credentials.get_api_key("gemini")
if stored_key then
return stored_key
@@ -27,7 +27,7 @@ end
---@return string Model name
local function get_model()
-- Priority: stored credentials > config
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_model = credentials.get_model("gemini")
if stored_model then
return stored_model

View File

@@ -9,7 +9,7 @@ local llm = require("codetyper.core.llm")
---@return string Host URL
local function get_host()
-- Priority: stored credentials > config
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_host = credentials.get_ollama_host()
if stored_host then
return stored_host
@@ -24,7 +24,7 @@ end
---@return string Model name
local function get_model()
-- Priority: stored credentials > config
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_model = credentials.get_model("ollama")
if stored_model then
return stored_model

View File

@@ -12,7 +12,7 @@ local API_URL = "https://api.openai.com/v1/chat/completions"
---@return string|nil API key
local function get_api_key()
-- Priority: stored credentials > config > environment
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_key = credentials.get_api_key("openai")
if stored_key then
return stored_key
@@ -27,7 +27,7 @@ end
---@return string Model name
local function get_model()
-- Priority: stored credentials > config
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_model = credentials.get_model("openai")
if stored_model then
return stored_model
@@ -42,7 +42,7 @@ end
---@return string API endpoint
local function get_endpoint()
-- Priority: stored credentials > config > default
local credentials = require("codetyper.credentials")
local credentials = require("codetyper.config.credentials")
local stored_endpoint = credentials.get_endpoint("openai")
if stored_endpoint then
return stored_endpoint

View File

@@ -0,0 +1,616 @@
---@mod codetyper.agent.executor Tool executor for agent system
---
--- Executes tools requested by the LLM and returns results.
local M = {}
local utils = require("codetyper.support.utils")
local logs = require("codetyper.adapters.nvim.ui.logs")
---@class ExecutionResult
---@field success boolean Whether the execution succeeded
---@field result string Result message or content
---@field requires_approval boolean Whether user approval is needed
---@field diff_data? DiffData Data for diff preview (if requires_approval)
--- Open a file in a buffer (in a non-agent window)
---@param path string File path to open
---@param jump_to_line? number Optional line number to jump to
local function open_file_in_buffer(path, jump_to_line)
if not path or path == "" then
return
end
-- Check if file exists
if vim.fn.filereadable(path) ~= 1 then
return
end
vim.schedule(function()
-- Find a suitable window (not the agent UI windows)
local target_win = nil
local agent_ui_ok, agent_ui = pcall(require, "codetyper.agent.ui")
for _, win in ipairs(vim.api.nvim_list_wins()) do
local buf = vim.api.nvim_win_get_buf(win)
local buftype = vim.bo[buf].buftype
-- Skip special buffers (agent UI, nofile, etc.)
if buftype == "" or buftype == "acwrite" then
-- Check if this is not an agent UI window
local is_agent_win = false
if agent_ui_ok and agent_ui.is_open() then
-- Skip agent windows by checking if it's one of our special buffers
local bufname = vim.api.nvim_buf_get_name(buf)
if bufname == "" then
-- Could be agent buffer, check by buffer option
is_agent_win = vim.bo[buf].buftype == "nofile"
end
end
if not is_agent_win then
target_win = win
break
end
end
end
-- If no suitable window found, create a new split
if not target_win then
-- Get the rightmost non-agent window or create one
vim.cmd("rightbelow vsplit")
target_win = vim.api.nvim_get_current_win()
end
-- Open the file in the target window
vim.api.nvim_set_current_win(target_win)
vim.cmd("edit " .. vim.fn.fnameescape(path))
-- Jump to line if specified
if jump_to_line and jump_to_line > 0 then
local line_count = vim.api.nvim_buf_line_count(0)
local target_line = math.min(jump_to_line, line_count)
vim.api.nvim_win_set_cursor(target_win, { target_line, 0 })
vim.cmd("normal! zz")
end
end)
end
--- Expose open_file_in_buffer for external use
M.open_file_in_buffer = open_file_in_buffer
---@class DiffData
---@field path string File path
---@field original string Original content
---@field modified string Modified content
---@field operation string Operation type: "edit", "create", "overwrite", "bash"
--- Execute a tool and return result via callback
---@param tool_name string Name of the tool to execute
---@param parameters table Tool parameters
---@param callback fun(result: ExecutionResult) Callback with result
function M.execute(tool_name, parameters, callback)
local handlers = {
read_file = M.handle_read_file,
edit_file = M.handle_edit_file,
write_file = M.handle_write_file,
bash = M.handle_bash,
delete_file = M.handle_delete_file,
list_directory = M.handle_list_directory,
search_files = M.handle_search_files,
}
local handler = handlers[tool_name]
if not handler then
callback({
success = false,
result = "Unknown tool: " .. tool_name,
requires_approval = false,
})
return
end
handler(parameters, callback)
end
--- Handle read_file tool
---@param params table { path: string }
---@param callback fun(result: ExecutionResult)
function M.handle_read_file(params, callback)
local path = M.resolve_path(params.path)
-- Log the read operation in Claude Code style
local relative_path = vim.fn.fnamemodify(path, ":~:.")
logs.read(relative_path)
local content = utils.read_file(path)
if content then
-- Log how many lines were read
local lines = vim.split(content, "\n", { plain = true })
logs.add({ type = "result", message = string.format(" ⎿ Read %d lines", #lines) })
-- Open the file in a buffer so user can see it
open_file_in_buffer(path)
callback({
success = true,
result = content,
requires_approval = false,
})
else
logs.add({ type = "error", message = " ⎿ File not found" })
callback({
success = false,
result = "Could not read file: " .. path,
requires_approval = false,
})
end
end
--- Handle edit_file tool
---@param params table { path: string, find: string, replace: string }
---@param callback fun(result: ExecutionResult)
function M.handle_edit_file(params, callback)
local path = M.resolve_path(params.path)
local relative_path = vim.fn.fnamemodify(path, ":~:.")
-- Log the edit operation
logs.add({ type = "action", message = string.format("Edit(%s)", relative_path) })
local original = utils.read_file(path)
if not original then
logs.add({ type = "error", message = " ⎿ File not found" })
callback({
success = false,
result = "File not found: " .. path,
requires_approval = false,
})
return
end
-- Try to find and replace the content
local escaped_find = utils.escape_pattern(params.find)
local new_content, count = original:gsub(escaped_find, params.replace, 1)
if count == 0 then
logs.add({ type = "error", message = " ⎿ Content not found" })
callback({
success = false,
result = "Could not find content to replace in: " .. path,
requires_approval = false,
})
return
end
-- Calculate lines changed
local original_lines = #vim.split(original, "\n", { plain = true })
local new_lines = #vim.split(new_content, "\n", { plain = true })
local diff = new_lines - original_lines
if diff > 0 then
logs.add({ type = "result", message = string.format(" ⎿ +%d lines (pending approval)", diff) })
elseif diff < 0 then
logs.add({ type = "result", message = string.format(" ⎿ %d lines (pending approval)", diff) })
else
logs.add({ type = "result", message = " ⎿ Modified (pending approval)" })
end
-- Requires user approval - show diff
callback({
success = true,
result = "Edit prepared for: " .. path,
requires_approval = true,
diff_data = {
path = path,
original = original,
modified = new_content,
operation = "edit",
},
})
end
--- Handle write_file tool
---@param params table { path: string, content: string }
---@param callback fun(result: ExecutionResult)
function M.handle_write_file(params, callback)
local path = M.resolve_path(params.path)
local relative_path = vim.fn.fnamemodify(path, ":~:.")
local original = utils.read_file(path) or ""
local operation = original == "" and "create" or "overwrite"
-- Log the write operation
if operation == "create" then
logs.add({ type = "action", message = string.format("Write(%s)", relative_path) })
local new_lines = #vim.split(params.content, "\n", { plain = true })
logs.add({ type = "result", message = string.format(" ⎿ New file (%d lines, pending approval)", new_lines) })
else
logs.add({ type = "action", message = string.format("Update(%s)", relative_path) })
local original_lines = #vim.split(original, "\n", { plain = true })
local new_lines = #vim.split(params.content, "\n", { plain = true })
local diff = new_lines - original_lines
if diff > 0 then
logs.add({ type = "result", message = string.format(" ⎿ +%d lines (pending approval)", diff) })
elseif diff < 0 then
logs.add({ type = "result", message = string.format(" ⎿ %d lines (pending approval)", diff) })
else
logs.add({ type = "result", message = " ⎿ Modified (pending approval)" })
end
end
-- Ensure parent directory exists
local dir = vim.fn.fnamemodify(path, ":h")
if dir ~= "" and dir ~= "." then
utils.ensure_dir(dir)
end
callback({
success = true,
result = (operation == "create" and "Create" or "Overwrite") .. " prepared for: " .. path,
requires_approval = true,
diff_data = {
path = path,
original = original,
modified = params.content,
operation = operation,
},
})
end
--- Handle bash tool
---@param params table { command: string, timeout?: number }
---@param callback fun(result: ExecutionResult)
function M.handle_bash(params, callback)
local command = params.command
-- Log the bash operation
logs.add({ type = "action", message = string.format("Bash(%s)", command:sub(1, 50) .. (#command > 50 and "..." or "")) })
logs.add({ type = "result", message = " ⎿ Pending approval" })
-- Requires user approval first
callback({
success = true,
result = "Command: " .. command,
requires_approval = true,
diff_data = {
path = "[bash]",
original = "",
modified = "$ " .. command,
operation = "bash",
},
bash_command = command,
bash_timeout = params.timeout or 30000,
})
end
--- Handle delete_file tool
---@param params table { path: string, reason: string }
---@param callback fun(result: ExecutionResult)
function M.handle_delete_file(params, callback)
local path = M.resolve_path(params.path)
local reason = params.reason or "No reason provided"
-- Check if file exists
if not utils.file_exists(path) then
callback({
success = false,
result = "File not found: " .. path,
requires_approval = false,
})
return
end
-- Read content for showing in diff (so user knows what they're deleting)
local content = utils.read_file(path) or "[Could not read file]"
callback({
success = true,
result = "Delete: " .. path .. " (" .. reason .. ")",
requires_approval = true,
diff_data = {
path = path,
original = content,
modified = "", -- Empty = deletion
operation = "delete",
reason = reason,
},
})
end
--- Handle list_directory tool
---@param params table { path?: string, recursive?: boolean }
---@param callback fun(result: ExecutionResult)
function M.handle_list_directory(params, callback)
local path = params.path and M.resolve_path(params.path) or (utils.get_project_root() or vim.fn.getcwd())
local recursive = params.recursive or false
-- Use vim.fn.readdir or glob for directory listing
local entries = {}
local function list_dir(dir, depth)
if depth > 3 then
return
end
local ok, files = pcall(vim.fn.readdir, dir)
if not ok or not files then
return
end
for _, name in ipairs(files) do
if name ~= "." and name ~= ".." and not name:match("^%.git$") and not name:match("^node_modules$") then
local full_path = dir .. "/" .. name
local stat = vim.loop.fs_stat(full_path)
if stat then
local prefix = string.rep(" ", depth)
local type_indicator = stat.type == "directory" and "/" or ""
table.insert(entries, prefix .. name .. type_indicator)
if recursive and stat.type == "directory" then
list_dir(full_path, depth + 1)
end
end
end
end
end
list_dir(path, 0)
local result = "Directory: " .. path .. "\n\n" .. table.concat(entries, "\n")
callback({
success = true,
result = result,
requires_approval = false,
})
end
--- Handle search_files tool
---@param params table { pattern?: string, content?: string, path?: string }
---@param callback fun(result: ExecutionResult)
function M.handle_search_files(params, callback)
local search_path = params.path and M.resolve_path(params.path) or (utils.get_project_root() or vim.fn.getcwd())
local pattern = params.pattern
local content_search = params.content
local results = {}
if pattern then
-- Search by file name pattern using glob
local glob_pattern = search_path .. "/**/" .. pattern
local files = vim.fn.glob(glob_pattern, false, true)
for _, file in ipairs(files) do
-- Skip common ignore patterns
if not file:match("node_modules") and not file:match("%.git/") then
local relative = file:gsub(search_path .. "/", "")
table.insert(results, relative)
end
end
end
if content_search then
-- Search by content using grep
local grep_results = {}
local grep_cmd = string.format("grep -rl '%s' '%s' 2>/dev/null | head -20", content_search:gsub("'", "\\'"), search_path)
local handle = io.popen(grep_cmd)
if handle then
for line in handle:lines() do
if not line:match("node_modules") and not line:match("%.git/") then
local relative = line:gsub(search_path .. "/", "")
table.insert(grep_results, relative)
end
end
handle:close()
end
-- Merge with pattern results or use as primary results
if #results == 0 then
results = grep_results
else
-- Intersection of pattern and content results
local pattern_set = {}
for _, f in ipairs(results) do
pattern_set[f] = true
end
results = {}
for _, f in ipairs(grep_results) do
if pattern_set[f] then
table.insert(results, f)
end
end
end
end
local result_text = "Search results"
if pattern then
result_text = result_text .. " (pattern: " .. pattern .. ")"
end
if content_search then
result_text = result_text .. " (content: " .. content_search .. ")"
end
result_text = result_text .. ":\n\n"
if #results == 0 then
result_text = result_text .. "No files found."
else
result_text = result_text .. table.concat(results, "\n")
end
callback({
success = true,
result = result_text,
requires_approval = false,
})
end
--- Actually apply an approved change
---@param diff_data DiffData The diff data to apply
---@param callback fun(result: ExecutionResult)
function M.apply_change(diff_data, callback)
if diff_data.operation == "bash" then
-- Extract command from modified (remove "$ " prefix)
local command = diff_data.modified:gsub("^%$ ", "")
M.execute_bash_command(command, 30000, callback)
elseif diff_data.operation == "delete" then
-- Delete file
local ok, err = os.remove(diff_data.path)
if ok then
-- Close buffer if it's open
M.close_buffer_if_open(diff_data.path)
callback({
success = true,
result = "Deleted: " .. diff_data.path,
requires_approval = false,
})
else
callback({
success = false,
result = "Failed to delete: " .. diff_data.path .. " (" .. (err or "unknown error") .. ")",
requires_approval = false,
})
end
else
-- Write file
local success = utils.write_file(diff_data.path, diff_data.modified)
if success then
-- Open and/or reload buffer so user can see the changes
open_file_in_buffer(diff_data.path)
M.reload_buffer_if_open(diff_data.path)
callback({
success = true,
result = "Changes applied to: " .. diff_data.path,
requires_approval = false,
})
else
callback({
success = false,
result = "Failed to write: " .. diff_data.path,
requires_approval = false,
})
end
end
end
--- Execute a bash command
---@param command string Command to execute
---@param timeout number Timeout in milliseconds
---@param callback fun(result: ExecutionResult)
function M.execute_bash_command(command, timeout, callback)
local stdout_data = {}
local stderr_data = {}
local job_id
job_id = vim.fn.jobstart(command, {
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(_, data)
if data then
for _, line in ipairs(data) do
if line ~= "" then
table.insert(stdout_data, line)
end
end
end
end,
on_stderr = function(_, data)
if data then
for _, line in ipairs(data) do
if line ~= "" then
table.insert(stderr_data, line)
end
end
end
end,
on_exit = function(_, exit_code)
vim.schedule(function()
local result = table.concat(stdout_data, "\n")
if #stderr_data > 0 then
if result ~= "" then
result = result .. "\n"
end
result = result .. "STDERR:\n" .. table.concat(stderr_data, "\n")
end
result = result .. "\n[Exit code: " .. exit_code .. "]"
callback({
success = exit_code == 0,
result = result,
requires_approval = false,
})
end)
end,
})
-- Set up timeout
if job_id > 0 then
vim.defer_fn(function()
if vim.fn.jobwait({ job_id }, 0)[1] == -1 then
vim.fn.jobstop(job_id)
vim.schedule(function()
callback({
success = false,
result = "Command timed out after " .. timeout .. "ms",
requires_approval = false,
})
end)
end
end, timeout)
else
callback({
success = false,
result = "Failed to start command",
requires_approval = false,
})
end
end
--- Reload a buffer if it's currently open
---@param filepath string Path to the file
function M.reload_buffer_if_open(filepath)
local full_path = vim.fn.fnamemodify(filepath, ":p")
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf)
if buf_name == full_path then
vim.api.nvim_buf_call(buf, function()
vim.cmd("edit!")
end)
break
end
end
end
end
--- Close a buffer if it's currently open (for deleted files)
---@param filepath string Path to the file
function M.close_buffer_if_open(filepath)
local full_path = vim.fn.fnamemodify(filepath, ":p")
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf)
if buf_name == full_path then
-- Force close the buffer
pcall(vim.api.nvim_buf_delete, buf, { force = true })
break
end
end
end
end
--- Resolve a path (expand ~ and make absolute if needed)
---@param path string Path to resolve
---@return string Resolved path
function M.resolve_path(path)
-- Expand ~ to home directory
local expanded = vim.fn.expand(path)
-- If relative, make it relative to project root or cwd
if not vim.startswith(expanded, "/") then
local root = utils.get_project_root() or vim.fn.getcwd()
expanded = root .. "/" .. expanded
end
return vim.fn.fnamemodify(expanded, ":p")
end
return M

View File

@@ -0,0 +1,381 @@
---@mod codetyper.agent.loop Agent loop with tool orchestration
---@brief [[
--- Main agent loop that handles multi-turn conversations with tool use.
--- Agent execution loop with tool calling support.
---@brief ]]
local M = {}
local prompts = require("codetyper.prompts.agents.loop")
---@class AgentMessage
---@field role "system"|"user"|"assistant"|"tool"
---@field content string|table
---@field tool_call_id? string For tool responses
---@field tool_calls? table[] For assistant tool calls
---@field name? string Tool name for tool responses
---@class AgentLoopOpts
---@field system_prompt string System prompt
---@field user_input string Initial user message
---@field tools? CoderTool[] Available tools (default: all registered)
---@field max_iterations? number Max tool call iterations (default: 10)
---@field provider? string LLM provider to use
---@field on_start? fun() Called when loop starts
---@field on_chunk? fun(chunk: string) Called for each response chunk
---@field on_tool_call? fun(name: string, input: table) Called before tool execution
---@field on_tool_result? fun(name: string, result: any, error: string|nil) Called after tool execution
---@field on_message? fun(message: AgentMessage) Called for each message added
---@field on_complete? fun(result: string|nil, error: string|nil) Called when loop completes
---@field session_ctx? table Session context shared across tools
--- Format tool definitions for OpenAI-compatible API
---@param tools CoderTool[]
---@return table[]
local function format_tools_for_api(tools)
local formatted = {}
for _, tool in ipairs(tools) do
local properties = {}
local required = {}
for _, param in ipairs(tool.params or {}) do
properties[param.name] = {
type = param.type == "integer" and "number" or param.type,
description = param.description,
}
if not param.optional then
table.insert(required, param.name)
end
end
table.insert(formatted, {
type = "function",
["function"] = {
name = tool.name,
description = type(tool.description) == "function" and tool.description() or tool.description,
parameters = {
type = "object",
properties = properties,
required = required,
},
},
})
end
return formatted
end
--- Parse tool calls from LLM response
---@param response table LLM response
---@return table[] tool_calls
local function parse_tool_calls(response)
local tool_calls = {}
-- Handle different response formats
if response.tool_calls then
-- OpenAI format
for _, call in ipairs(response.tool_calls) do
local args = call["function"].arguments
if type(args) == "string" then
local ok, parsed = pcall(vim.json.decode, args)
if ok then
args = parsed
end
end
table.insert(tool_calls, {
id = call.id,
name = call["function"].name,
input = args,
})
end
elseif response.content and type(response.content) == "table" then
-- Claude format (content blocks)
for _, block in ipairs(response.content) do
if block.type == "tool_use" then
table.insert(tool_calls, {
id = block.id,
name = block.name,
input = block.input,
})
end
end
end
return tool_calls
end
--- Build messages for LLM request
---@param history AgentMessage[]
---@return table[]
local function build_messages(history)
local messages = {}
for _, msg in ipairs(history) do
if msg.role == "system" then
table.insert(messages, {
role = "system",
content = msg.content,
})
elseif msg.role == "user" then
table.insert(messages, {
role = "user",
content = msg.content,
})
elseif msg.role == "assistant" then
local message = {
role = "assistant",
content = msg.content,
}
if msg.tool_calls then
message.tool_calls = msg.tool_calls
end
table.insert(messages, message)
elseif msg.role == "tool" then
table.insert(messages, {
role = "tool",
tool_call_id = msg.tool_call_id,
content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content),
})
end
end
return messages
end
--- Execute the agent loop
---@param opts AgentLoopOpts
function M.run(opts)
local tools_mod = require("codetyper.core.tools")
local llm = require("codetyper.core.llm")
-- Get tools
local tools = opts.tools or tools_mod.list()
local tool_map = {}
for _, tool in ipairs(tools) do
tool_map[tool.name] = tool
end
-- Initialize conversation history
---@type AgentMessage[]
local history = {
{ role = "system", content = opts.system_prompt },
{ role = "user", content = opts.user_input },
}
local session_ctx = opts.session_ctx or {}
local max_iterations = opts.max_iterations or 10
local iteration = 0
-- Callback wrappers
local function on_message(msg)
if opts.on_message then
opts.on_message(msg)
end
end
-- Notify of initial messages
for _, msg in ipairs(history) do
on_message(msg)
end
-- Start notification
if opts.on_start then
opts.on_start()
end
--- Process one iteration of the loop
local function process_iteration()
iteration = iteration + 1
if iteration > max_iterations then
if opts.on_complete then
opts.on_complete(nil, "Max iterations reached")
end
return
end
-- Build request
local messages = build_messages(history)
local formatted_tools = format_tools_for_api(tools)
-- Build context for LLM
local context = {
file_content = "",
language = "lua",
extension = "lua",
prompt_type = "agent",
tools = formatted_tools,
}
-- Get LLM response
local client = llm.get_client()
if not client then
if opts.on_complete then
opts.on_complete(nil, "No LLM client available")
end
return
end
-- Build prompt from messages
local prompt_parts = {}
for _, msg in ipairs(messages) do
if msg.role ~= "system" then
table.insert(prompt_parts, string.format("[%s]: %s", msg.role, msg.content or ""))
end
end
local prompt = table.concat(prompt_parts, "\n\n")
client.generate(prompt, context, function(response, error)
if error then
if opts.on_complete then
opts.on_complete(nil, error)
end
return
end
-- Chunk callback
if opts.on_chunk then
opts.on_chunk(response)
end
-- Parse response for tool calls
-- For now, we'll use a simple heuristic to detect tool calls in the response
-- In a full implementation, the LLM would return structured tool calls
local tool_calls = {}
-- Try to parse JSON tool calls from response
local json_match = response:match("```json%s*(%b{})%s*```")
if json_match then
local ok, parsed = pcall(vim.json.decode, json_match)
if ok and parsed.tool_calls then
tool_calls = parsed.tool_calls
end
end
-- Add assistant message
local assistant_msg = {
role = "assistant",
content = response,
tool_calls = #tool_calls > 0 and tool_calls or nil,
}
table.insert(history, assistant_msg)
on_message(assistant_msg)
-- Process tool calls
if #tool_calls > 0 then
local pending = #tool_calls
local results = {}
for i, call in ipairs(tool_calls) do
local tool = tool_map[call.name]
if not tool then
results[i] = { error = "Unknown tool: " .. call.name }
pending = pending - 1
else
-- Notify of tool call
if opts.on_tool_call then
opts.on_tool_call(call.name, call.input)
end
-- Execute tool
local tool_opts = {
on_log = function(msg)
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({ type = "tool", message = msg })
end)
end,
on_complete = function(result, err)
results[i] = { result = result, error = err }
pending = pending - 1
-- Notify of tool result
if opts.on_tool_result then
opts.on_tool_result(call.name, result, err)
end
-- Add tool response to history
local tool_msg = {
role = "tool",
tool_call_id = call.id or tostring(i),
name = call.name,
content = err or result,
}
table.insert(history, tool_msg)
on_message(tool_msg)
-- Continue loop when all tools complete
if pending == 0 then
vim.schedule(process_iteration)
end
end,
session_ctx = session_ctx,
}
-- Validate and execute
local valid, validation_err = true, nil
if tool.validate_input then
valid, validation_err = tool:validate_input(call.input)
end
if not valid then
tool_opts.on_complete(nil, validation_err)
else
local result, err = tool.func(call.input, tool_opts)
-- If sync result, call on_complete
if result ~= nil or err ~= nil then
tool_opts.on_complete(result, err)
end
end
end
end
else
-- No tool calls - loop complete
if opts.on_complete then
opts.on_complete(response, nil)
end
end
end)
end
-- Start the loop
process_iteration()
end
--- Create an agent with default settings
---@param task string Task description
---@param opts? AgentLoopOpts Additional options
function M.create(task, opts)
opts = opts or {}
local system_prompt = opts.system_prompt or prompts.default_system_prompt
M.run(vim.tbl_extend("force", opts, {
system_prompt = system_prompt,
user_input = task,
}))
end
--- Simple dispatch agent for sub-tasks
---@param prompt string Task for the sub-agent
---@param on_complete fun(result: string|nil, error: string|nil) Completion callback
---@param opts? table Additional options
function M.dispatch(prompt, on_complete, opts)
opts = opts or {}
-- Sub-agents get limited tools by default
local tools_mod = require("codetyper.core.tools")
local safe_tools = tools_mod.list(function(tool)
return tool.name == "view" or tool.name == "grep" or tool.name == "glob"
end)
M.run({
system_prompt = prompts.dispatch_prompt,
user_input = prompt,
tools = opts.tools or safe_tools,
max_iterations = opts.max_iterations or 5,
on_complete = on_complete,
session_ctx = opts.session_ctx,
})
end
return M

View File

@@ -0,0 +1,155 @@
---@mod codetyper.agent.resume Resume context for agent sessions
---
--- Saves and loads agent state to allow continuing long-running tasks.
local M = {}
local utils = require("codetyper.support.utils")
--- Get the resume context directory
---@return string|nil
local function get_resume_dir()
local root = utils.get_project_root() or vim.fn.getcwd()
return root .. "/.coder/tmp"
end
--- Get the resume context file path
---@return string|nil
local function get_resume_path()
local dir = get_resume_dir()
if not dir then
return nil
end
return dir .. "/agent_resume.json"
end
--- Ensure the resume directory exists
---@return boolean
local function ensure_resume_dir()
local dir = get_resume_dir()
if not dir then
return false
end
return utils.ensure_dir(dir)
end
---@class ResumeContext
---@field conversation table[] Message history
---@field pending_tool_results table[] Pending results
---@field iteration number Current iteration count
---@field original_prompt string Original user prompt
---@field timestamp number When saved
---@field project_root string Project root path
--- Save the current agent state for resuming later
---@param conversation table[] Conversation history
---@param pending_results table[] Pending tool results
---@param iteration number Current iteration
---@param original_prompt string Original prompt
---@return boolean Success
function M.save(conversation, pending_results, iteration, original_prompt)
if not ensure_resume_dir() then
return false
end
local path = get_resume_path()
if not path then
return false
end
local context = {
conversation = conversation,
pending_tool_results = pending_results,
iteration = iteration,
original_prompt = original_prompt,
timestamp = os.time(),
project_root = utils.get_project_root() or vim.fn.getcwd(),
}
local ok, json = pcall(vim.json.encode, context)
if not ok then
utils.notify("Failed to encode resume context", vim.log.levels.ERROR)
return false
end
local success = utils.write_file(path, json)
if success then
utils.notify("Agent state saved. Use /continue to resume.", vim.log.levels.INFO)
end
return success
end
--- Load saved agent state
---@return ResumeContext|nil
function M.load()
local path = get_resume_path()
if not path then
return nil
end
local content = utils.read_file(path)
if not content or content == "" then
return nil
end
local ok, context = pcall(vim.json.decode, content)
if not ok or not context then
return nil
end
return context
end
--- Check if there's a saved resume context
---@return boolean
function M.has_saved_state()
local path = get_resume_path()
if not path then
return false
end
return vim.fn.filereadable(path) == 1
end
--- Get info about saved state (for display)
---@return table|nil
function M.get_info()
local context = M.load()
if not context then
return nil
end
local age_seconds = os.time() - (context.timestamp or 0)
local age_str
if age_seconds < 60 then
age_str = age_seconds .. " seconds ago"
elseif age_seconds < 3600 then
age_str = math.floor(age_seconds / 60) .. " minutes ago"
else
age_str = math.floor(age_seconds / 3600) .. " hours ago"
end
return {
prompt = context.original_prompt,
iteration = context.iteration,
messages = #context.conversation,
saved_at = age_str,
project = context.project_root,
}
end
--- Clear saved resume context
---@return boolean
function M.clear()
local path = get_resume_path()
if not path then
return false
end
if vim.fn.filereadable(path) == 1 then
os.remove(path)
return true
end
return false
end
return M

View File

@@ -0,0 +1,756 @@
---@mod codetyper.agent.scheduler Event scheduler with completion-awareness
---@brief [[
--- Central orchestrator for the event-driven system.
--- Handles dispatch, escalation, and completion-safe injection.
---@brief ]]
local M = {}
local queue = require("codetyper.core.events.queue")
local patch = require("codetyper.core.diff.patch")
local worker = require("codetyper.core.scheduler.worker")
local confidence_mod = require("codetyper.core.llm.confidence")
local context_modal = require("codetyper.adapters.nvim.ui.context_modal")
local params = require("codetyper.params.agents.scheduler")
-- Setup context modal cleanup on exit
context_modal.setup()
--- Scheduler state
local state = {
running = false,
timer = nil,
poll_interval = 100, -- ms
paused = false,
config = params.config,
}
--- Autocommand group for injection timing
local augroup = nil
--- Check if completion popup is visible
---@return boolean
function M.is_completion_visible()
-- Check native popup menu
if vim.fn.pumvisible() == 1 then
return true
end
-- Check nvim-cmp
local ok, cmp = pcall(require, "cmp")
if ok and cmp.visible and cmp.visible() then
return true
end
-- Check coq_nvim
local coq_ok, coq = pcall(require, "coq")
if coq_ok and coq and type(coq.visible) == "function" and coq.visible() then
return true
end
return false
end
--- Check if we're in insert mode
---@return boolean
function M.is_insert_mode()
local mode = vim.fn.mode()
return mode == "i" or mode == "ic" or mode == "ix"
end
--- Check if it's safe to inject code
---@return boolean
---@return string|nil reason if not safe
function M.is_safe_to_inject()
if M.is_completion_visible() then
return false, "completion_visible"
end
if M.is_insert_mode() then
return false, "insert_mode"
end
return true, nil
end
--- Get the provider for escalation
---@return string
local function get_remote_provider()
local ok, codetyper = pcall(require, "codetyper")
if ok then
local config = codetyper.get_config()
if config and config.llm and config.llm.provider then
-- If current provider is ollama, use configured remote
if config.llm.provider == "ollama" then
-- Check which remote provider is configured
if config.llm.openai and config.llm.openai.api_key then
return "openai"
elseif config.llm.gemini and config.llm.gemini.api_key then
return "gemini"
elseif config.llm.copilot then
return "copilot"
end
end
return config.llm.provider
end
end
return state.config.remote_provider
end
--- Get the primary provider (ollama if scout enabled, else configured)
---@return string
local function get_primary_provider()
if state.config.ollama_scout then
return "ollama"
end
local ok, codetyper = pcall(require, "codetyper")
if ok then
local config = codetyper.get_config()
if config and config.llm and config.llm.provider then
return config.llm.provider
end
end
return "ollama"
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, attached_files)
-- 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
-- Preserve any attached files provided by the context modal
if attached_files and #attached_files > 0 then
new_event.attached_files = attached_files
end
-- Log the retry
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.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
--- Try to parse requested file paths from an LLM response asking for more context
---@param response string
---@return string[] list of resolved full paths
local function parse_requested_files(response)
if not response or response == "" then
return {}
end
local cwd = vim.fn.getcwd()
local results = {}
local seen = {}
-- Heuristics: capture backticked paths, lines starting with - or *, or raw paths with slashes and extension
for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do
if not seen[path] then
table.insert(results, path)
seen[path] = true
end
end
for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do
if not seen[path] then
-- Filter out common English words that match the pattern
if not path:match("^[Ii]$") and not path:match("^[Tt]his$") then
table.insert(results, path)
seen[path] = true
end
end
end
-- Also capture list items like '- src/foo.lua'
for line in response:gmatch("[^\\n]+") do
local m = line:match("^%s*[-*]%s*([%w%._%-%/]+%.[%w_]+)%s*$")
if m and not seen[m] then
table.insert(results, m)
seen[m] = true
end
end
-- Resolve each candidate to a full path by checking cwd and globbing
local resolved = {}
for _, p in ipairs(results) do
local candidate = p
local full = nil
-- If absolute or already rooted
if candidate:sub(1,1) == "/" and vim.fn.filereadable(candidate) == 1 then
full = candidate
else
-- Try relative to cwd
local try1 = cwd .. "/" .. candidate
if vim.fn.filereadable(try1) == 1 then
full = try1
else
-- Try globbing for filename anywhere in project
local basename = candidate
-- If candidate contains slashes, try the tail
local tail = candidate:match("[^/]+$") or candidate
local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true)
if matches and #matches > 0 then
full = matches[1]
end
end
end
if full and vim.fn.filereadable(full) == 1 then
table.insert(resolved, full)
end
end
return resolved
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.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Event %s: LLM needs more context, opening modal", event.id),
})
end)
-- Try to auto-attach any files the LLM specifically requested in its response
local requested = parse_requested_files(result.response or "")
-- Detect suggested shell commands the LLM may want executed (e.g., "run ls -la", "please run git status")
local function detect_suggested_commands(response)
if not response then
return {}
end
local cmds = {}
-- capture backticked commands: `ls -la`
for c in response:gmatch("`([^`]+)`") do
if #c > 1 and not c:match("%-%-help") then
table.insert(cmds, { label = c, cmd = c })
end
end
-- capture phrases like: run ls -la or run `ls -la`
for m in response:gmatch("[Rr]un%s+([%w%p%s%-_/]+)") do
local cand = m:gsub("^%s+",""):gsub("%s+$","")
if cand and #cand > 1 then
-- ignore long sentences; keep first line or command-like substring
local line = cand:match("[^\n]+") or cand
line = line:gsub("and then.*","")
line = line:gsub("please.*","")
if not line:match("%a+%s+files") then
table.insert(cmds, { label = line, cmd = line })
end
end
end
-- dedupe
local seen = {}
local out = {}
for _, v in ipairs(cmds) do
if v.cmd and not seen[v.cmd] then
seen[v.cmd] = true
table.insert(out, v)
end
end
return out
end
local suggested_cmds = detect_suggested_commands(result.response or "")
if suggested_cmds and #suggested_cmds > 0 then
-- Open modal and show suggested commands for user approval
context_modal.open(result.original_event or event, result.response or "", retry_with_context, suggested_cmds)
queue.update_status(event.id, "needs_context", { response = result.response })
return
end
if requested and #requested > 0 then
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({ type = "info", message = string.format("Auto-attaching %d requested file(s)", #requested) })
end)
-- Build attached_files entries
local attached = event.attached_files or {}
for _, full in ipairs(requested) do
local ok, content = pcall(function()
return table.concat(vim.fn.readfile(full), "\n")
end)
if ok and content then
table.insert(attached, {
path = vim.fn.fnamemodify(full, ":~:."),
full_path = full,
content = content,
})
end
end
-- Retry automatically with same prompt but attached files
local new_event = vim.deepcopy(result.original_event or event)
new_event.id = nil
new_event.attached_files = attached
new_event.attempt_count = 0
new_event.status = nil
queue.enqueue(new_event)
queue.update_status(event.id, "needs_context", { response = result.response })
return
end
-- If no files parsed, open modal for manual context entry
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
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format(
"Escalating event %s to remote provider (ollama failed)",
event.id
),
})
end)
event.attempt_count = event.attempt_count + 1
event.status = "pending"
event.worker_type = get_remote_provider()
return
end
-- Mark as failed
queue.update_status(event.id, "failed", { error = result.error })
return
end
-- Success - check confidence
local needs_escalation = confidence_mod.needs_escalation(
result.confidence,
state.config.escalation_threshold
)
if needs_escalation and result.worker_type == "ollama" and event.attempt_count < 2 then
-- Low confidence from ollama - escalate to remote
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format(
"Escalating event %s to remote provider (confidence: %.2f < %.2f)",
event.id, result.confidence, state.config.escalation_threshold
),
})
end)
event.attempt_count = event.attempt_count + 1
event.status = "pending"
event.worker_type = get_remote_provider()
return
end
-- Good enough or final attempt - create patch
local p = patch.create_from_event(event, result.response, result.confidence)
patch.queue_patch(p)
queue.complete(event.id)
-- 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.adapters.nvim.ui.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
local function dispatch_next()
if state.paused then
return
end
-- Check concurrent limit
if worker.active_count() >= state.config.max_concurrent then
return
end
-- Get next pending event
local event = queue.dequeue()
if not event then
return
end
-- Check for precedence conflicts (multiple tags in same scope)
local should_skip, skip_reason = queue.check_precedence(event)
if should_skip then
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "warning",
message = string.format("Event %s skipped: %s", event.id, skip_reason or "conflict"),
})
end)
queue.cancel(event.id)
-- Try next event
return dispatch_next()
end
-- Determine which provider to use
local provider = event.worker_type or get_primary_provider()
-- Log dispatch with intent/scope info
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
local intent_info = event.intent and event.intent.type or "unknown"
local scope_info = event.scope and event.scope.type ~= "file"
and string.format("%s:%s", event.scope.type, event.scope.name or "anon")
or "file"
logs.add({
type = "info",
message = string.format(
"Dispatching %s [intent: %s, scope: %s, provider: %s]",
event.id, intent_info, scope_info, provider
),
})
end)
-- Create worker
worker.create(event, provider, function(result)
vim.schedule(function()
handle_worker_result(event, result)
end)
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_smart()
if applied > 0 or stale > 0 then
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Patches flushed: %d applied, %d stale", applied, stale),
})
end)
end
else
-- 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.adapters.nvim.ui.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
--- Main scheduler loop
local function scheduler_loop()
if not state.running then
return
end
dispatch_next()
-- Cleanup old items periodically
if math.random() < 0.01 then -- ~1% chance each tick
queue.cleanup(300)
patch.cleanup(300)
end
-- Schedule next tick
state.timer = vim.defer_fn(scheduler_loop, state.poll_interval)
end
--- Setup autocommands for injection timing
local function setup_autocmds()
if augroup then
pcall(vim.api.nvim_del_augroup_by_id, augroup)
end
augroup = vim.api.nvim_create_augroup("CodetypeScheduler", { clear = true })
-- Flush patches when leaving insert mode
vim.api.nvim_create_autocmd("InsertLeave", {
group = augroup,
callback = function()
vim.defer_fn(function()
if not M.is_completion_visible() then
patch.flush_pending_smart()
end
end, state.config.completion_delay_ms)
end,
desc = "Flush pending patches on InsertLeave",
})
-- Flush patches on cursor hold
vim.api.nvim_create_autocmd("CursorHold", {
group = augroup,
callback = function()
if not M.is_insert_mode() and not M.is_completion_visible() then
patch.flush_pending_smart()
end
end,
desc = "Flush pending patches on CursorHold",
})
-- Cancel patches when buffer changes significantly
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
callback = function(ev)
-- Mark relevant patches as potentially stale
-- They'll be checked on next flush attempt
end,
desc = "Check patch staleness on save",
})
-- Cleanup when buffer is deleted
vim.api.nvim_create_autocmd("BufDelete", {
group = augroup,
callback = function(ev)
queue.cancel_for_buffer(ev.buf)
patch.cancel_for_buffer(ev.buf)
worker.cancel_for_event(ev.buf)
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
---@param config table|nil Configuration overrides
function M.start(config)
if state.running then
return
end
-- Merge config
if config then
for k, v in pairs(config) do
state.config[k] = v
end
end
-- Load config from codetyper if available
pcall(function()
local codetyper = require("codetyper")
local ct_config = codetyper.get_config()
if ct_config and ct_config.scheduler then
for k, v in pairs(ct_config.scheduler) do
state.config[k] = v
end
end
end)
if not state.config.enabled then
return
end
state.running = true
state.paused = false
-- Setup autocmds
setup_autocmds()
-- Add queue listener
queue.add_listener(function(event_type, event, queue_size)
if event_type == "enqueue" and not state.paused then
-- New event - try to dispatch immediately
vim.schedule(dispatch_next)
end
end)
-- Start main loop
scheduler_loop()
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Scheduler started",
data = {
ollama_scout = state.config.ollama_scout,
escalation_threshold = state.config.escalation_threshold,
max_concurrent = state.config.max_concurrent,
},
})
end)
end
--- Stop the scheduler
function M.stop()
state.running = false
if state.timer then
pcall(function()
if type(state.timer) == "userdata" and state.timer.stop then
state.timer:stop()
end
end)
state.timer = nil
end
if augroup then
pcall(vim.api.nvim_del_augroup_by_id, augroup)
augroup = nil
end
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Scheduler stopped",
})
end)
end
--- Pause the scheduler (don't process new events)
function M.pause()
state.paused = true
end
--- Resume the scheduler
function M.resume()
state.paused = false
vim.schedule(dispatch_next)
end
--- Check if scheduler is running
---@return boolean
function M.is_running()
return state.running
end
--- Check if scheduler is paused
---@return boolean
function M.is_paused()
return state.paused
end
--- Get scheduler status
---@return table
function M.status()
return {
running = state.running,
paused = state.paused,
queue_stats = queue.stats(),
patch_stats = patch.stats(),
active_workers = worker.active_count(),
config = vim.deepcopy(state.config),
}
end
--- Manually trigger dispatch
function M.dispatch()
if state.running and not state.paused then
dispatch_next()
end
end
--- Force flush all pending patches (ignores completion check)
function M.force_flush()
return patch.flush_pending_smart()
end
--- Update configuration
---@param config table
function M.configure(config)
for k, v in pairs(config) do
state.config[k] = v
end
end
--- Queue a prompt for processing
--- This is a convenience function that creates a proper PromptEvent and enqueues it
---@param opts table Prompt options
--- - bufnr: number Source buffer number
--- - filepath: string Source file path
--- - target_path: string Target file for injection (can be same as filepath)
--- - prompt_content: string The cleaned prompt text
--- - range: {start_line: number, end_line: number} Line range of prompt tag
--- - source: string|nil Source identifier (e.g., "transform_command", "autocmd")
--- - priority: number|nil Priority (1=high, 2=normal, 3=low) default 2
---@return table The enqueued event
function M.queue_prompt(opts)
-- Build the PromptEvent structure
local event = {
bufnr = opts.bufnr,
filepath = opts.filepath,
target_path = opts.target_path or opts.filepath,
prompt_content = opts.prompt_content,
range = opts.range,
priority = opts.priority or 2,
source = opts.source or "manual",
-- Capture buffer state for staleness detection
changedtick = vim.api.nvim_buf_get_changedtick(opts.bufnr),
}
-- Enqueue through the queue module
return queue.enqueue(event)
end
return M

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
---@mod codetyper.agent.scope Tree-sitter scope resolution
---@brief [[
--- Resolves semantic scope for prompts using Tree-sitter.
--- Finds the smallest enclosing function/method/block for a given position.
---@brief ]]
local M = {}
---@class ScopeInfo
---@field type string "function"|"method"|"class"|"block"|"file"|"unknown"
---@field node_type string Tree-sitter node type
---@field range {start_row: number, start_col: number, end_row: number, end_col: number}
---@field text string The full text of the scope
---@field name string|nil Name of the function/class if available
--- Node types that represent function-like scopes per language
local params = require("codetyper.params.agents.scope")
local function_nodes = params.function_nodes
local class_nodes = params.class_nodes
local block_nodes = params.block_nodes
--- Check if Tree-sitter is available for buffer
---@param bufnr number
---@return boolean
function M.has_treesitter(bufnr)
-- 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
-- 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
-- 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
---@param bufnr number
---@param row number 0-indexed
---@param col number 0-indexed
---@return TSNode|nil
local function get_node_at_pos(bufnr, row, col)
local ok, ts_utils = pcall(require, "nvim-treesitter.ts_utils")
if not ok then
return nil
end
-- Try to get the node at the cursor position
local node = ts_utils.get_node_at_cursor()
if node then
return node
end
-- Fallback: get root and find node
local parser = vim.treesitter.get_parser(bufnr)
if not parser then
return nil
end
local tree = parser:parse()[1]
if not tree then
return nil
end
local root = tree:root()
return root:named_descendant_for_range(row, col, row, col)
end
--- Find enclosing scope node of specific types
---@param node TSNode
---@param node_types table<string, string>
---@return TSNode|nil, string|nil scope_type
local function find_enclosing_scope(node, node_types)
local current = node
while current do
local node_type = current:type()
if node_types[node_type] then
return current, node_types[node_type]
end
current = current:parent()
end
return nil, nil
end
--- Extract function/method name from node
---@param node TSNode
---@param bufnr number
---@return string|nil
local function get_scope_name(node, bufnr)
-- Try to find name child node
local name_node = node:field("name")[1]
if name_node then
return vim.treesitter.get_node_text(name_node, bufnr)
end
-- Try identifier child
for child in node:iter_children() do
if child:type() == "identifier" or child:type() == "property_identifier" then
return vim.treesitter.get_node_text(child, bufnr)
end
end
return nil
end
--- Resolve scope at position using Tree-sitter
---@param bufnr number Buffer number
---@param row number 1-indexed line number
---@param col number 1-indexed column number
---@return ScopeInfo
function M.resolve_scope(bufnr, row, col)
-- Default to file scope
local default_scope = {
type = "file",
node_type = "file",
range = {
start_row = 1,
start_col = 0,
end_row = vim.api.nvim_buf_line_count(bufnr),
end_col = 0,
},
text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n"),
name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":t"),
}
-- Check if Tree-sitter is available
if not M.has_treesitter(bufnr) then
-- Fall back to heuristic-based scope resolution
return M.resolve_scope_heuristic(bufnr, row, col) or default_scope
end
-- Convert to 0-indexed for Tree-sitter
local ts_row = row - 1
local ts_col = col - 1
-- Get node at position
local node = get_node_at_pos(bufnr, ts_row, ts_col)
if not node then
return default_scope
end
-- Try to find function scope first
local scope_node, scope_type = find_enclosing_scope(node, function_nodes)
-- If no function, try class
if not scope_node then
scope_node, scope_type = find_enclosing_scope(node, class_nodes)
end
-- If no class, try block
if not scope_node then
scope_node, scope_type = find_enclosing_scope(node, block_nodes)
end
if not scope_node then
return default_scope
end
-- Get range (convert back to 1-indexed)
local start_row, start_col, end_row, end_col = scope_node:range()
-- Get text
local text = vim.treesitter.get_node_text(scope_node, bufnr)
-- Get name
local name = get_scope_name(scope_node, bufnr)
return {
type = scope_type,
node_type = scope_node:type(),
range = {
start_row = start_row + 1,
start_col = start_col,
end_row = end_row + 1,
end_col = end_col,
},
text = text,
name = name,
}
end
--- Heuristic fallback for scope resolution (no Tree-sitter)
---@param bufnr number
---@param row number 1-indexed
---@param col number 1-indexed
---@return ScopeInfo|nil
function M.resolve_scope_heuristic(bufnr, row, col)
_ = col -- unused in heuristic
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local filetype = vim.bo[bufnr].filetype
-- Language-specific function patterns
local patterns = {
lua = {
start = "^%s*local%s+function%s+",
start_alt = "^%s*function%s+",
ending = "^%s*end%s*$",
},
python = {
start = "^%s*def%s+",
start_alt = "^%s*async%s+def%s+",
ending = nil, -- Python uses indentation
},
javascript = {
start = "^%s*export%s+function%s+",
start_alt = "^%s*function%s+",
start_alt2 = "^%s*export%s+const%s+%w+%s*=",
start_alt3 = "^%s*const%s+%w+%s*=%s*",
start_alt4 = "^%s*export%s+async%s+function%s+",
start_alt5 = "^%s*async%s+function%s+",
ending = "^%s*}%s*$",
},
typescript = {
start = "^%s*export%s+function%s+",
start_alt = "^%s*function%s+",
start_alt2 = "^%s*export%s+const%s+%w+%s*=",
start_alt3 = "^%s*const%s+%w+%s*=%s*",
start_alt4 = "^%s*export%s+async%s+function%s+",
start_alt5 = "^%s*async%s+function%s+",
ending = "^%s*}%s*$",
},
}
local lang_patterns = patterns[filetype]
if not lang_patterns then
return nil
end
-- Find function start (search backwards)
local start_line = nil
for i = row, 1, -1 do
local line = lines[i]
-- Check all start patterns
if line:match(lang_patterns.start)
or (lang_patterns.start_alt and line:match(lang_patterns.start_alt))
or (lang_patterns.start_alt2 and line:match(lang_patterns.start_alt2))
or (lang_patterns.start_alt3 and line:match(lang_patterns.start_alt3))
or (lang_patterns.start_alt4 and line:match(lang_patterns.start_alt4))
or (lang_patterns.start_alt5 and line:match(lang_patterns.start_alt5)) then
start_line = i
break
end
end
if not start_line then
return nil
end
-- Find function end
local end_line = nil
if lang_patterns.ending then
-- Brace/end based languages
local depth = 0
for i = start_line, #lines do
local line = lines[i]
-- Count braces or end keywords
if filetype == "lua" then
if line:match("function") or line:match("if") or line:match("for") or line:match("while") then
depth = depth + 1
end
if line:match("^%s*end") then
depth = depth - 1
if depth <= 0 then
end_line = i
break
end
end
else
-- JavaScript/TypeScript brace counting
for _ in line:gmatch("{") do depth = depth + 1 end
for _ in line:gmatch("}") do depth = depth - 1 end
if depth <= 0 and i > start_line then
end_line = i
break
end
end
end
else
-- Python: use indentation
local base_indent = #(lines[start_line]:match("^%s*") or "")
for i = start_line + 1, #lines do
local line = lines[i]
if line:match("^%s*$") then
goto continue
end
local indent = #(line:match("^%s*") or "")
if indent <= base_indent then
end_line = i - 1
break
end
::continue::
end
end_line = end_line or #lines
end
if not end_line then
end_line = #lines
end
-- Extract text
local scope_lines = {}
for i = start_line, end_line do
table.insert(scope_lines, lines[i])
end
-- Try to extract function name
local name = nil
local first_line = lines[start_line]
name = first_line:match("function%s+([%w_]+)") or
first_line:match("def%s+([%w_]+)") or
first_line:match("const%s+([%w_]+)")
return {
type = "function",
node_type = "heuristic",
range = {
start_row = start_line,
start_col = 0,
end_row = end_line,
end_col = #lines[end_line],
},
text = table.concat(scope_lines, "\n"),
name = name,
}
end
--- Get scope for the current cursor position
---@return ScopeInfo
function M.resolve_scope_at_cursor()
local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
return M.resolve_scope(bufnr, cursor[1], cursor[2] + 1)
end
--- Check if position is inside a function/method
---@param bufnr number
---@param row number 1-indexed
---@param col number 1-indexed
---@return boolean
function M.is_in_function(bufnr, row, col)
local scope = M.resolve_scope(bufnr, row, col)
return scope.type == "function" or scope.type == "method"
end
--- Get all functions in buffer
---@param bufnr number
---@return ScopeInfo[]
function M.get_all_functions(bufnr)
local functions = {}
if not M.has_treesitter(bufnr) then
return functions
end
local parser = vim.treesitter.get_parser(bufnr)
if not parser then
return functions
end
local tree = parser:parse()[1]
if not tree then
return functions
end
local root = tree:root()
-- Query for all function nodes
local lang = parser:lang()
local query_string = [[
(function_declaration) @func
(function_definition) @func
(method_definition) @func
(arrow_function) @func
]]
local ok, query = pcall(vim.treesitter.query.parse, lang, query_string)
if not ok then
return functions
end
for _, node in query:iter_captures(root, bufnr, 0, -1) do
local start_row, start_col, end_row, end_col = node:range()
local text = vim.treesitter.get_node_text(node, bufnr)
local name = get_scope_name(node, bufnr)
table.insert(functions, {
type = function_nodes[node:type()] or "function",
node_type = node:type(),
range = {
start_row = start_row + 1,
start_col = start_col,
end_row = end_row + 1,
end_col = end_col,
},
text = text,
name = name,
})
end
return functions
end
return M

View File

@@ -0,0 +1,35 @@
M.params = {
{
name = "command",
description = "The shell command to execute",
type = "string",
},
{
name = "cwd",
description = "Working directory for the command (optional)",
type = "string",
optional = true,
},
{
name = "timeout",
description = "Timeout in milliseconds (default: 120000)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "stdout",
description = "Command output",
type = "string",
},
{
name = "error",
description = "Error message if command failed",
type = "string",
optional = true,
},
}
return M

View File

@@ -0,0 +1,40 @@
---@mod codetyper.params.agents.confidence Parameters for confidence scoring
local M = {}
--- Heuristic weights (must sum to 1.0)
M.weights = {
length = 0.15, -- Response length relative to prompt
uncertainty = 0.30, -- Uncertainty phrases
syntax = 0.25, -- Syntax completeness
repetition = 0.15, -- Duplicate lines
truncation = 0.15, -- Incomplete ending
}
--- Uncertainty phrases that indicate low confidence
M.uncertainty_phrases = {
-- English
"i'm not sure",
"i am not sure",
"maybe",
"perhaps",
"might work",
"could work",
"not certain",
"uncertain",
"i think",
"possibly",
"TODO",
"FIXME",
"XXX",
"placeholder",
"implement this",
"fill in",
"your code here",
"...", -- Ellipsis as placeholder
"# TODO",
"// TODO",
"-- TODO",
"/* TODO",
}
return M

View File

@@ -0,0 +1,33 @@
---@mod codetyper.params.agents.conflict Parameters for conflict resolution
local M = {}
--- Configuration defaults
M.config = {
-- Run linter check after accepting AI suggestions
lint_after_accept = true,
-- Auto-fix lint errors without prompting
auto_fix_lint_errors = true,
-- Auto-show menu after injecting conflict
auto_show_menu = true,
-- Auto-show menu for next conflict after resolving one
auto_show_next_menu = true,
}
--- Highlight groups
M.hl_groups = {
current = "CoderConflictCurrent",
current_label = "CoderConflictCurrentLabel",
incoming = "CoderConflictIncoming",
incoming_label = "CoderConflictIncomingLabel",
separator = "CoderConflictSeparator",
hint = "CoderConflictHint",
}
--- Conflict markers
M.markers = {
current_start = "<<<<<<< CURRENT",
separator = "=======",
incoming_end = ">>>>>>> INCOMING",
}
return M

View File

@@ -0,0 +1,48 @@
---@mod codetyper.params.agents.context Parameters for context building
local M = {}
--- Common ignore patterns
M.ignore_patterns = {
"^%.", -- Hidden files/dirs
"node_modules",
"%.git$",
"__pycache__",
"%.pyc$",
"target", -- Rust
"build",
"dist",
"%.o$",
"%.a$",
"%.so$",
"%.min%.",
"%.map$",
}
--- Key files that are important for understanding the project
M.important_files = {
["package.json"] = "Node.js project config",
["Cargo.toml"] = "Rust project config",
["go.mod"] = "Go module config",
["pyproject.toml"] = "Python project config",
["setup.py"] = "Python setup config",
["Makefile"] = "Build configuration",
["CMakeLists.txt"] = "CMake config",
[".gitignore"] = "Git ignore patterns",
["README.md"] = "Project documentation",
["init.lua"] = "Neovim plugin entry",
["plugin.lua"] = "Neovim plugin config",
}
--- Project type detection indicators
M.indicators = {
["package.json"] = { type = "node", language = "javascript/typescript" },
["Cargo.toml"] = { type = "rust", language = "rust" },
["go.mod"] = { type = "go", language = "go" },
["pyproject.toml"] = { type = "python", language = "python" },
["setup.py"] = { type = "python", language = "python" },
["Gemfile"] = { type = "ruby", language = "ruby" },
["pom.xml"] = { type = "maven", language = "java" },
["build.gradle"] = { type = "gradle", language = "java/kotlin" },
}
return M

View File

@@ -0,0 +1,33 @@
M.params = {
{
name = "path",
description = "Path to the file to edit",
type = "string",
},
{
name = "old_string",
description = "Text to find and replace (empty string to create new file or append)",
type = "string",
},
{
name = "new_string",
description = "Text to replace with",
type = "string",
},
}
M.returns = {
{
name = "success",
description = "Whether the edit was applied",
type = "boolean",
},
{
name = "error",
description = "Error message if edit failed",
type = "string",
optional = true,
},
}
return M

View File

@@ -0,0 +1,10 @@
M.description = [[Searches for a pattern in files using ripgrep.
Returns file paths and matching lines. Use this to find code by content.
Example patterns:
- "function foo" - Find function definitions
- "import.*react" - Find React imports
- "TODO|FIXME" - Find todo comments]]
return M

View File

@@ -0,0 +1,161 @@
---@mod codetyper.params.agents.intent Intent patterns and scope configuration
local M = {}
--- Intent patterns with associated metadata
M.intent_patterns = {
-- Complete: fill in missing implementation
complete = {
patterns = {
"complete",
"finish",
"implement",
"fill in",
"fill out",
"stub",
"todo",
"fixme",
},
scope_hint = "function",
action = "replace",
priority = 1,
},
-- Refactor: rewrite existing code
refactor = {
patterns = {
"refactor",
"rewrite",
"restructure",
"reorganize",
"clean up",
"cleanup",
"simplify",
"improve",
},
scope_hint = "function",
action = "replace",
priority = 2,
},
-- Fix: repair bugs or issues
fix = {
patterns = {
"fix",
"repair",
"correct",
"debug",
"solve",
"resolve",
"patch",
"bug",
"error",
"issue",
"update",
"modify",
"change",
"adjust",
"tweak",
},
scope_hint = "function",
action = "replace",
priority = 1,
},
-- Add: insert new code
add = {
patterns = {
"add",
"create",
"insert",
"include",
"append",
"new",
"generate",
"write",
},
scope_hint = nil, -- Could be anywhere
action = "insert",
priority = 3,
},
-- Document: add documentation
document = {
patterns = {
"document",
"comment",
"jsdoc",
"docstring",
"describe",
"annotate",
"type hint",
"typehint",
},
scope_hint = "function",
action = "replace", -- Replace with documented version
priority = 2,
},
-- Test: generate tests
test = {
patterns = {
"test",
"spec",
"unit test",
"integration test",
"coverage",
},
scope_hint = "file",
action = "append",
priority = 3,
},
-- Optimize: improve performance
optimize = {
patterns = {
"optimize",
"performance",
"faster",
"efficient",
"speed up",
"reduce",
"minimize",
},
scope_hint = "function",
action = "replace",
priority = 2,
},
-- Explain: provide explanation (no code change)
explain = {
patterns = {
"explain",
"what does",
"how does",
"why",
"describe",
"walk through",
"understand",
},
scope_hint = "function",
action = "none",
priority = 4,
},
}
--- Scope hint patterns
M.scope_patterns = {
["this function"] = "function",
["this method"] = "function",
["the function"] = "function",
["the method"] = "function",
["this class"] = "class",
["the class"] = "class",
["this file"] = "file",
["the file"] = "file",
["this block"] = "block",
["the block"] = "block",
["this"] = nil, -- Use Tree-sitter to determine
["here"] = nil,
}
return M

View File

@@ -0,0 +1,87 @@
---@mod codetyper.params.agents.languages Language-specific patterns and configurations
local M = {}
--- Language-specific import patterns
M.import_patterns = {
-- JavaScript/TypeScript
javascript = {
{ pattern = "^%s*import%s+.+%s+from%s+['\"]", multi_line = true },
{ pattern = "^%s*import%s+['\"]", multi_line = false },
{ pattern = "^%s*import%s*{", multi_line = true },
{ pattern = "^%s*import%s*%*", multi_line = true },
{ pattern = "^%s*export%s+{.+}%s+from%s+['\"]", multi_line = true },
{ pattern = "^%s*const%s+%w+%s*=%s*require%(['\"]", multi_line = false },
{ pattern = "^%s*let%s+%w+%s*=%s*require%(['\"]", multi_line = false },
{ pattern = "^%s*var%s+%w+%s*=%s*require%(['\"]", multi_line = false },
},
-- Python
python = {
{ pattern = "^%s*import%s+%w", multi_line = false },
{ pattern = "^%s*from%s+[%w%.]+%s+import%s+", multi_line = true },
},
-- Lua
lua = {
{ pattern = "^%s*local%s+%w+%s*=%s*require%s*%(?['\"]", multi_line = false },
{ pattern = "^%s*require%s*%(?['\"]", multi_line = false },
},
-- Go
go = {
{ pattern = "^%s*import%s+%(?", multi_line = true },
},
-- Rust
rust = {
{ pattern = "^%s*use%s+", multi_line = true },
{ pattern = "^%s*extern%s+crate%s+", multi_line = false },
},
-- C/C++
c = {
{ pattern = "^%s*#include%s*[<\"]", multi_line = false },
},
-- Java/Kotlin
java = {
{ pattern = "^%s*import%s+", multi_line = false },
},
-- Ruby
ruby = {
{ pattern = "^%s*require%s+['\"]", multi_line = false },
{ pattern = "^%s*require_relative%s+['\"]", multi_line = false },
},
-- PHP
php = {
{ pattern = "^%s*use%s+", multi_line = false },
{ pattern = "^%s*require%s+['\"]", multi_line = false },
{ pattern = "^%s*require_once%s+['\"]", multi_line = false },
{ pattern = "^%s*include%s+['\"]", multi_line = false },
{ pattern = "^%s*include_once%s+['\"]", multi_line = false },
},
}
-- Alias common extensions to language configs
M.import_patterns.ts = M.import_patterns.javascript
M.import_patterns.tsx = M.import_patterns.javascript
M.import_patterns.jsx = M.import_patterns.javascript
M.import_patterns.mjs = M.import_patterns.javascript
M.import_patterns.cjs = M.import_patterns.javascript
M.import_patterns.py = M.import_patterns.python
M.import_patterns.cpp = M.import_patterns.c
M.import_patterns.hpp = M.import_patterns.c
M.import_patterns.h = M.import_patterns.c
M.import_patterns.kt = M.import_patterns.java
M.import_patterns.rs = M.import_patterns.rust
M.import_patterns.rb = M.import_patterns.ruby
--- Language-specific comment patterns
M.comment_patterns = {
lua = { "^%-%-" },
python = { "^#" },
javascript = { "^//", "^/%*", "^%*" },
typescript = { "^//", "^/%*", "^%*" },
go = { "^//", "^/%*", "^%*" },
rust = { "^//", "^/%*", "^%*" },
c = { "^//", "^/%*", "^%*", "^#" },
java = { "^//", "^/%*", "^%*" },
ruby = { "^#" },
php = { "^//", "^/%*", "^%*", "^#" },
}
return M

View File

@@ -0,0 +1,15 @@
---@mod codetyper.params.agents.linter Linter configuration
local M = {}
M.config = {
-- Auto-save file after code injection
auto_save = true,
-- Delay in ms to wait for LSP diagnostics to update
diagnostic_delay_ms = 500,
-- Severity levels to check (1=Error, 2=Warning, 3=Info, 4=Hint)
min_severity = vim.diagnostic.severity.WARN,
-- Auto-offer to fix lint errors
auto_offer_fix = true,
}
return M

View File

@@ -0,0 +1,36 @@
---@mod codetyper.params.agents.logs Log parameters
local M = {}
M.icons = {
start = "->",
success = "OK",
error = "ERR",
approval = "??",
approved = "YES",
rejected = "NO",
}
M.level_icons = {
info = "i",
debug = ".",
request = ">",
response = "<",
tool = "T",
error = "!",
warning = "?",
success = "i",
queue = "Q",
patch = "P",
}
M.thinking_types = { "thinking", "reason", "action", "task", "result" }
M.thinking_prefixes = {
thinking = "",
reason = "",
action = "",
task = "",
result = "",
}
return M

View File

@@ -0,0 +1,15 @@
---@mod codetyper.params.agents.parser Parser regex patterns
local M = {}
M.patterns = {
fenced_json = "```json%s*(%b{})%s*```",
inline_json = '(%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%})',
}
M.defaults = {
stop_reason = "end_turn",
tool_stop_reason = "tool_use",
replacement_text = "[Tool call]",
}
return M

View File

@@ -0,0 +1,12 @@
---@mod codetyper.params.agents.patch Patch configuration
local M = {}
M.config = {
snapshot_range = 5, -- Lines above/below prompt to snapshot
clean_interval_ms = 60000, -- Check for stale patches every minute
max_age_ms = 3600000, -- 1 hour TTL
staleness_check = true,
use_search_replace_parser = true, -- Enable new parsing logic
}
return M

View File

@@ -0,0 +1,47 @@
---@mod codetyper.params.agents.permissions Dangerous and safe command patterns
local M = {}
--- Dangerous command patterns that should never be auto-allowed
M.dangerous_patterns = {
"^rm%s+%-rf",
"^rm%s+%-r%s+/",
"^rm%s+/",
"^sudo%s+rm",
"^chmod%s+777",
"^chmod%s+%-R",
"^chown%s+%-R",
"^dd%s+",
"^mkfs",
"^fdisk",
"^format",
":.*>%s*/dev/",
"^curl.*|.*sh",
"^wget.*|.*sh",
"^eval%s+",
"`;.*`",
"%$%(.*%)",
"fork%s*bomb",
}
--- Safe command patterns that can be auto-allowed
M.safe_patterns = {
"^ls%s",
"^ls$",
"^cat%s",
"^head%s",
"^tail%s",
"^grep%s",
"^find%s",
"^pwd$",
"^echo%s",
"^wc%s",
"^git%s+status",
"^git%s+diff",
"^git%s+log",
"^git%s+show",
"^git%s+branch",
"^git%s+checkout",
"^git%s+add", -- Generally safe if reviewing changes
}
return M

View File

@@ -0,0 +1,14 @@
---@mod codetyper.params.agents.scheduler Scheduler configuration
local M = {}
M.config = {
enabled = true,
ollama_scout = true,
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
apply_delay_ms = 5000, -- Wait before applying code
remote_provider = "copilot", -- Default fallback provider
}
return M

View File

@@ -0,0 +1,72 @@
---@mod codetyper.params.agents.scope Tree-sitter scope mappings
local M = {}
--- Node types that represent function-like scopes per language
M.function_nodes = {
-- Lua
["function_declaration"] = "function",
["function_definition"] = "function",
["local_function"] = "function",
["function"] = "function",
-- JavaScript/TypeScript
["function_declaration"] = "function",
["function_expression"] = "function",
["arrow_function"] = "function",
["method_definition"] = "method",
["function"] = "function",
-- Python
["function_definition"] = "function",
["lambda"] = "function",
-- Go
["function_declaration"] = "function",
["method_declaration"] = "method",
["func_literal"] = "function",
-- Rust
["function_item"] = "function",
["closure_expression"] = "function",
-- C/C++
["function_definition"] = "function",
["lambda_expression"] = "function",
-- Java
["method_declaration"] = "method",
["constructor_declaration"] = "method",
["lambda_expression"] = "function",
-- Ruby
["method"] = "method",
["singleton_method"] = "method",
["lambda"] = "function",
["block"] = "function",
-- PHP
["function_definition"] = "function",
["method_declaration"] = "method",
["arrow_function"] = "function",
}
--- Node types that represent class-like scopes
M.class_nodes = {
["class_declaration"] = "class",
["class_definition"] = "class",
["struct_declaration"] = "class",
["impl_item"] = "class", -- Rust config
["interface_declaration"] = "class",
["trait_item"] = "class",
}
--- Node types that represent block scopes
M.block_nodes = {
["block"] = "block",
["do_statement"] = "block", -- Lua
["if_statement"] = "block",
["for_statement"] = "block",
["while_statement"] = "block",
}
return M

View File

@@ -0,0 +1,11 @@
---@mod codetyper.params.agents.search_replace Search/Replace patterns
local M = {}
M.patterns = {
dash_style = "%-%-%-%-%-%-%-?%s*SEARCH%s*\n(.-)\n=======%s*\n(.-)\n%+%+%+%+%+%+%+?%s*REPLACE",
claude_style = "<<<<<<<[%s]*SEARCH%s*\n(.-)\n=======%s*\n(.-)\n>>>>>>>[%s]*REPLACE",
simple_style = "%[SEARCH%]%s*\n(.-)\n%[REPLACE%]%s*\n(.-)\n%[END%]",
diff_block = "```diff\n(.-)\n```",
}
return M

View File

@@ -0,0 +1,147 @@
---@mod codetyper.params.agents.tools Tool definitions
local M = {}
--- Tool definitions in a provider-agnostic format
M.definitions = {
read_file = {
name = "read_file",
description = "Read the contents of a file at the specified path",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "The path to the file to read",
},
start_line = {
type = "number",
description = "Optional start line number (1-indexed)",
},
end_line = {
type = "number",
description = "Optional end line number (1-indexed)",
},
},
required = { "path" },
},
},
edit_file = {
name = "edit_file",
description = "Edit a file by replacing specific content. Provide the exact content to find and the replacement.",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "The path to the file to edit",
},
find = {
type = "string",
description = "The exact content to replace",
},
replace = {
type = "string",
description = "The new content",
},
},
required = { "path", "find", "replace" },
},
},
write_file = {
name = "write_file",
description = "Write content to a file, creating it if it doesn't exist or overwriting if it does",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "The path to the file to write",
},
content = {
type = "string",
description = "The content to write",
},
},
required = { "path", "content" },
},
},
bash = {
name = "bash",
description = "Execute a bash command and return the output. Use for git, npm, build tools, etc.",
parameters = {
type = "object",
properties = {
command = {
type = "string",
description = "The bash command to execute",
},
},
required = { "command" },
},
},
delete_file = {
name = "delete_file",
description = "Delete a file",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "The path to the file to delete",
},
reason = {
type = "string",
description = "Reason for deletion",
},
},
required = { "path", "reason" },
},
},
list_directory = {
name = "list_directory",
description = "List files and directories in a path",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "The path to list",
},
recursive = {
type = "boolean",
description = "Whether to list recursively",
},
},
required = { "path" },
},
},
search_files = {
name = "search_files",
description = "Search for files by name/glob pattern or content",
parameters = {
type = "object",
properties = {
pattern = {
type = "string",
description = "Glob pattern to search for filenames",
},
content = {
type = "string",
description = "Content string to search for within files",
},
path = {
type = "string",
description = "The root path to start search",
},
},
},
},
}
return M

View File

@@ -0,0 +1,37 @@
local M = {}
M.params = {
{
name = "path",
description = "Path to the file (relative to project root or absolute)",
type = "string",
},
{
name = "start_line",
description = "Line number to start reading (1-indexed)",
type = "integer",
optional = true,
},
{
name = "end_line",
description = "Line number to end reading (1-indexed, inclusive)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "content",
description = "File contents as JSON with content, total_line_count, is_truncated",
type = "string",
},
{
name = "error",
description = "Error message if file could not be read",
type = "string",
optional = true,
},
}
return M

View File

@@ -0,0 +1,30 @@
---@mod codetyper.params.agents.worker Worker configuration and patterns
local M = {}
--- Patterns that indicate LLM needs more context (must be near start of response)
M.context_needed_patterns = {
"I need to see",
"Could you provide",
"Please provide",
"Can you show",
"don't have enough context",
"need more information",
"cannot see the definition",
"missing the implementation",
"I would need to check",
"please share",
"Please upload",
"could not find",
}
--- Default timeouts by provider type
M.default_timeouts = {
openai = 60000, -- 60s
anthropic = 90000, -- 90s
google = 60000, -- 60s
ollama = 120000, -- 120s (local models can be slower)
copilot = 60000, -- 60s
default = 60000,
}
return M

View File

@@ -0,0 +1,30 @@
local M = {}
M.params = {
{
name = "path",
description = "Path to the file to write",
type = "string",
},
{
name = "content",
description = "Content to write to the file",
type = "string",
},
}
M.returns = {
{
name = "success",
description = "Whether the file was written successfully",
type = "boolean",
},
{
name = "error",
description = "Error message if write failed",
type = "string",
optional = true,
},
}
return M

View File

@@ -7,20 +7,20 @@ local utils = require("codetyper.support.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 = "@/",
}
}
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
@@ -29,145 +29,145 @@ end
---@param close_tag string Closing tag
---@return CoderPrompt[] List of found prompts
function M.find_prompts(content, open_tag, close_tag)
local prompts = {}
local escaped_open = utils.escape_pattern(open_tag)
local escaped_close = utils.escape_pattern(close_tag)
local prompts = {}
local escaped_open = utils.escape_pattern(open_tag)
local escaped_close = utils.escape_pattern(close_tag)
local lines = vim.split(content, "\n", { plain = true })
local in_prompt = false
local current_prompt = nil
local prompt_content = {}
local lines = vim.split(content, "\n", { plain = true })
local in_prompt = false
local current_prompt = nil
local prompt_content = {}
for line_num, line in ipairs(lines) do
if not in_prompt then
-- Look for opening tag
local start_col = line:find(escaped_open)
if start_col then
in_prompt = true
current_prompt = {
start_line = line_num,
start_col = start_col,
content = "",
}
-- Get content after opening tag on same line
local after_tag = line:sub(start_col + #open_tag)
local end_col = after_tag:find(escaped_close)
if end_col then
-- Single line prompt
current_prompt.content = after_tag:sub(1, end_col - 1)
current_prompt.end_line = line_num
current_prompt.end_col = start_col + #open_tag + end_col + #close_tag - 2
table.insert(prompts, current_prompt)
in_prompt = false
current_prompt = nil
else
table.insert(prompt_content, after_tag)
end
end
else
-- Look for closing tag
local end_col = line:find(escaped_close)
if end_col then
-- Found closing tag
local before_tag = line:sub(1, end_col - 1)
table.insert(prompt_content, before_tag)
current_prompt.content = table.concat(prompt_content, "\n")
current_prompt.end_line = line_num
current_prompt.end_col = end_col + #close_tag - 1
table.insert(prompts, current_prompt)
in_prompt = false
current_prompt = nil
prompt_content = {}
else
table.insert(prompt_content, line)
end
end
end
for line_num, line in ipairs(lines) do
if not in_prompt then
-- Look for opening tag
local start_col = line:find(escaped_open)
if start_col then
in_prompt = true
current_prompt = {
start_line = line_num,
start_col = start_col,
content = "",
}
-- Get content after opening tag on same line
local after_tag = line:sub(start_col + #open_tag)
local end_col = after_tag:find(escaped_close)
if end_col then
-- Single line prompt
current_prompt.content = after_tag:sub(1, end_col - 1)
current_prompt.end_line = line_num
current_prompt.end_col = start_col + #open_tag + end_col + #close_tag - 2
table.insert(prompts, current_prompt)
in_prompt = false
current_prompt = nil
else
table.insert(prompt_content, after_tag)
end
end
else
-- Look for closing tag
local end_col = line:find(escaped_close)
if end_col then
-- Found closing tag
local before_tag = line:sub(1, end_col - 1)
table.insert(prompt_content, before_tag)
current_prompt.content = table.concat(prompt_content, "\n")
current_prompt.end_line = line_num
current_prompt.end_col = end_col + #close_tag - 1
table.insert(prompts, current_prompt)
in_prompt = false
current_prompt = nil
prompt_content = {}
else
table.insert(prompt_content, line)
end
end
end
return prompts
return prompts
end
--- Find prompts in a buffer
---@param bufnr number Buffer number
---@return CoderPrompt[] List of found prompts
function M.find_prompts_in_buffer(bufnr)
local config = get_config_safe()
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
return M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag)
return M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag)
end
--- Get prompt at cursor position
---@param bufnr? number Buffer number (default: current)
---@return CoderPrompt|nil Prompt at cursor or nil
function M.get_prompt_at_cursor(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local col = cursor[2] + 1 -- Convert to 1-indexed
bufnr = bufnr or vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local col = cursor[2] + 1 -- Convert to 1-indexed
local prompts = M.find_prompts_in_buffer(bufnr)
local prompts = M.find_prompts_in_buffer(bufnr)
for _, prompt in ipairs(prompts) do
if line >= prompt.start_line and line <= prompt.end_line then
if line == prompt.start_line and col < prompt.start_col then
goto continue
end
if line == prompt.end_line and col > prompt.end_col then
goto continue
end
return prompt
end
::continue::
end
for _, prompt in ipairs(prompts) do
if line >= prompt.start_line and line <= prompt.end_line then
if line == prompt.start_line and col < prompt.start_col then
goto continue
end
if line == prompt.end_line and col > prompt.end_col then
goto continue
end
return prompt
end
::continue::
end
return nil
return nil
end
--- Get the last closed prompt in buffer
---@param bufnr? number Buffer number (default: current)
---@return CoderPrompt|nil Last prompt or nil
function M.get_last_prompt(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local prompts = M.find_prompts_in_buffer(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local prompts = M.find_prompts_in_buffer(bufnr)
if #prompts > 0 then
return prompts[#prompts]
end
if #prompts > 0 then
return prompts[#prompts]
end
return nil
return nil
end
--- Extract the prompt type from content
---@param content string Prompt content
---@return "refactor" | "add" | "document" | "explain" | "generic" Prompt type
function M.detect_prompt_type(content)
local lower = content:lower()
local lower = content:lower()
if lower:match("refactor") then
return "refactor"
elseif lower:match("add") or lower:match("create") or lower:match("implement") then
return "add"
elseif lower:match("document") or lower:match("comment") or lower:match("jsdoc") then
return "document"
elseif lower:match("explain") or lower:match("what") or lower:match("how") then
return "explain"
end
if lower:match("refactor") then
return "refactor"
elseif lower:match("add") or lower:match("create") or lower:match("implement") then
return "add"
elseif lower:match("document") or lower:match("comment") or lower:match("jsdoc") then
return "document"
elseif lower:match("explain") or lower:match("what") or lower:match("how") then
return "explain"
end
return "generic"
return "generic"
end
--- Clean prompt content (trim whitespace, normalize newlines)
---@param content string Raw prompt content
---@return string Cleaned content
function M.clean_prompt(content)
-- Trim leading/trailing whitespace
content = content:match("^%s*(.-)%s*$")
-- Normalize multiple newlines
content = content:gsub("\n\n\n+", "\n\n")
return content
-- Trim leading/trailing whitespace
content = content:match("^%s*(.-)%s*$")
-- Normalize multiple newlines
content = content:gsub("\n\n\n+", "\n\n")
return content
end
--- Check if line contains a closing tag
@@ -175,26 +175,26 @@ end
---@param close_tag string Closing tag
---@return boolean
function M.has_closing_tag(line, close_tag)
return line:find(utils.escape_pattern(close_tag)) ~= nil
return line:find(utils.escape_pattern(close_tag)) ~= nil
end
--- Check if buffer has any unclosed prompts
---@param bufnr? number Buffer number (default: current)
---@return boolean
function M.has_unclosed_prompts(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local config = get_config_safe()
bufnr = bufnr or vim.api.nvim_get_current_buf()
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
local escaped_open = utils.escape_pattern(config.patterns.open_tag)
local escaped_close = utils.escape_pattern(config.patterns.close_tag)
local escaped_open = utils.escape_pattern(config.patterns.open_tag)
local escaped_close = utils.escape_pattern(config.patterns.close_tag)
local _, open_count = content:gsub(escaped_open, "")
local _, close_count = content:gsub(escaped_close, "")
local _, open_count = content:gsub(escaped_open, "")
local _, close_count = content:gsub(escaped_close, "")
return open_count > close_count
return open_count > close_count
end
--- Extract file references from prompt content
@@ -202,25 +202,25 @@ end
---@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
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%._%-/]*)", "")
-- 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
@@ -228,61 +228,61 @@ end
---@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()
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 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 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
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
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
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()
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 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)
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%._%-/]*)$")
-- 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
-- Make sure it's not the closing tag pattern
if prefix and before_cursor:sub(-2) == "@/" then
return nil
end
return prefix
return prefix
end
return M

View File

@@ -0,0 +1,16 @@
M.description = [[Executes a bash command in a shell.
IMPORTANT RULES:
- Do NOT use bash to read files (use 'view' tool instead)
- Do NOT use bash to modify files (use 'write' or 'edit' tools instead)
- Do NOT use interactive commands (vim, nano, less, etc.)
- Commands timeout after 2 minutes by default
Allowed uses:
- Running builds (make, npm run build, cargo build)
- Running tests (npm test, pytest, cargo test)
- Git operations (git status, git diff, git commit)
- Package management (npm install, pip install)
- System info commands (ls, pwd, which)]]
return M

View File

@@ -0,0 +1,66 @@
---@mod codetyper.prompts.agents.diff Prompts and UI strings for diff view and bash approval
local M = {}
--- Bash approval dialog strings
M.bash_approval = {
title = " BASH COMMAND APPROVAL",
divider = " " .. string.rep("", 56),
command_label = " Command:",
warning_prefix = " ⚠️ WARNING: ",
options = {
" [y] Allow once - Execute this command",
" [s] Allow this session - Auto-allow until restart",
" [a] Add to allow list - Always allow this command",
" [n] Reject - Cancel execution",
},
cancel_hint = " Press key to choose | [q] or [Esc] to cancel",
}
--- Diff view help message
M.diff_help = {
{ "Diff: ", "Normal" },
{ "{path}", "Directory" },
{ " | ", "Normal" },
{ "y/<CR>", "Keyword" },
{ " approve ", "Normal" },
{ "n/q/<Esc>", "Keyword" },
{ " reject ", "Normal" },
{ "<Tab>", "Keyword" },
{ " switch panes", "Normal" },
}
--- Review UI interface strings
M.review = {
diff_header = {
top = "╭─ %s %s %s ─────────────────────────────────────",
path = "│ %s",
op = "│ Operation: %s",
status = "│ Status: %s",
bottom = "╰────────────────────────────────────────────────────",
},
list_menu = {
top = "╭─ Changes (%s) ──────────╮",
items = {
"│ │",
"│ j/k: navigate │",
"│ Enter: view diff │",
"│ a: approve r: reject │",
"│ A: approve all │",
"│ q: close │",
},
bottom = "╰──────────────────────────────╯",
},
status = {
applied = "Applied",
approved = "Approved",
pending = "Pending",
},
messages = {
no_changes = " No changes to review",
no_changes_short = "No changes to review",
applied_count = "Applied %d change(s)",
},
}
return M

View File

@@ -0,0 +1,14 @@
M.description = [[Makes a targeted edit to a file by replacing text.
The old_string should match the content you want to replace. The tool uses multiple
matching strategies with fallbacks:
1. Exact match
2. Whitespace-normalized match
3. Indentation-flexible match
4. Line-trimmed match
5. Fuzzy anchor-based match
For creating new files, use old_string="" and provide the full content in new_string.
For large changes, consider using 'write' tool instead.]]
return M

View File

@@ -0,0 +1,41 @@
M.params = {
{
name = "pattern",
description = "Regular expression pattern to search for",
type = "string",
},
{
name = "path",
description = "Directory or file to search in (default: project root)",
type = "string",
optional = true,
},
{
name = "include",
description = "File glob pattern to include (e.g., '*.lua')",
type = "string",
optional = true,
},
{
name = "max_results",
description = "Maximum number of results (default: 50)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "matches",
description = "JSON array of matches with file, line_number, and content",
type = "string",
},
{
name = "error",
description = "Error message if search failed",
type = "string",
optional = true,
},
}
return M

View File

@@ -0,0 +1,141 @@
---@mod codetyper.prompts.agents.init Agent prompts for Codetyper.nvim
---
--- System prompts for the agentic mode with tool use.
local M = {}
--- Build the system prompt with project context
---@return string System prompt with context
function M.build_system_prompt()
local base = M.system
-- Add project context
local ok, context_builder = pcall(require, "codetyper.agent.context_builder")
if ok then
local context = context_builder.build_full_context()
if context and context ~= "" then
base = base .. "\n\n=== PROJECT CONTEXT ===\n" .. context .. "\n=== END PROJECT CONTEXT ===\n"
end
end
return base .. "\n\n" .. M.tool_instructions
end
--- System prompt for agent mode
M.system =
[[You are an expert AI coding assistant integrated into Neovim. You MUST use the provided tools to accomplish tasks.
## CRITICAL: YOU MUST USE TOOLS
**NEVER output code in your response text.** Instead, you MUST call the write_file tool to create files.
WRONG (do NOT do this):
```python
print("hello")
```
RIGHT (do this instead):
Call the write_file tool with path="hello.py" and content="print(\"hello\")\n"
## AVAILABLE TOOLS
### File Operations
- **read_file**: Read any file. Parameters: path (string)
- **write_file**: Create or overwrite files. Parameters: path (string), content (string)
- **edit_file**: Modify existing files. Parameters: path (string), find (string), replace (string)
- **list_directory**: List files and directories. Parameters: path (string, optional), recursive (boolean, optional)
- **search_files**: Find files. Parameters: pattern (string), content (string), path (string)
- **delete_file**: Delete a file. Parameters: path (string), reason (string)
### Shell Commands
- **bash**: Run shell commands. Parameters: command (string), timeout (number, optional)
## HOW TO WORK
1. **To create a file**: Call write_file with the path and complete content
2. **To modify a file**: First call read_file, then call edit_file with exact find/replace strings
3. **To run commands**: Call bash with the command string
## EXAMPLE
User: "Create a Python hello world"
Your action: Call the write_file tool:
- path: "hello.py"
- content: "#!/usr/bin/env python3\nprint('Hello, World!')\n"
Then provide a brief summary.
## RULES
1. **ALWAYS call tools** - Never just show code in text, always use write_file
2. **Read before editing** - Use read_file before edit_file
3. **Complete files** - write_file content must be the entire file
4. **Be precise** - edit_file "find" must match exactly including whitespace
5. **Act, don't describe** - Use tools to make changes, don't just explain what to do
]]
--- Tool usage instructions appended to system prompt
M.tool_instructions = [[
## MANDATORY TOOL CALLING
You MUST call tools to perform actions. Your response should include tool calls, not code blocks.
When the user asks you to create a file:
→ Call write_file with path and content parameters
When the user asks you to modify a file:
→ Call read_file first, then call edit_file
When the user asks you to run a command:
→ Call bash with the command
## REMEMBER
- Outputting code in triple backticks does NOT create a file
- You must explicitly call write_file to create any file
- After tool execution, provide only a brief summary
- Do not repeat code that was written - just confirm what was done
]]
--- Prompt for when agent finishes
M.completion = [[Provide a concise summary of what was changed.
Include:
- Files that were read or modified
- The nature of the changes (high-level)
- Any follow-up steps or recommendations, if applicable
Do NOT restate tool output verbatim.
]]
--- Text-based tool calling instructions
M.tool_instructions_text = [[
## Available Tools
Call tools by outputting JSON in this format:
```json
{"tool": "tool_name", "arguments": {...}}
```
]]
--- Initial greeting when files are provided
M.initial_assistant_message = "I've reviewed the provided files. What would you like me to do?"
--- Format prefixes for text-based models
M.text_user_prefix = "User: "
M.text_assistant_prefix = "Assistant: "
--- Format file context
---@param files string[] Paths
---@return string Formatted context
function M.format_file_context(files)
local context = "# Initial Files\n"
for _, file_path in ipairs(files) do
local content = table.concat(vim.fn.readfile(file_path) or {}, "\n")
context = context .. string.format("\n## %s\n```\n%s\n```\n", file_path, content)
end
return context
end
return M

View File

@@ -0,0 +1,53 @@
---@mod codetyper.prompts.agents.intent Intent-specific system prompts
local M = {}
M.modifiers = {
complete = [[
You are completing an incomplete function.
Return the complete function with all missing parts filled in.
Keep the existing signature unless changes are required.
Output only the code, no explanations.]],
refactor = [[
You are refactoring existing code.
Improve the code structure while maintaining the same behavior.
Keep the function signature unchanged.
Output only the refactored code, no explanations.]],
fix = [[
You are fixing a bug in the code.
Identify and correct the issue while minimizing changes.
Preserve the original intent of the code.
Output only the fixed code, no explanations.]],
add = [[
You are adding new code.
Follow the existing code style and conventions.
Output only the new code to be inserted, no explanations.]],
document = [[
You are adding documentation to the code.
Add appropriate comments/docstrings for the function.
Include parameter types, return types, and description.
Output the complete function with documentation.]],
test = [[
You are generating tests for the code.
Create comprehensive unit tests covering edge cases.
Follow the testing conventions of the project.
Output only the test code, no explanations.]],
optimize = [[
You are optimizing code for performance.
Improve efficiency while maintaining correctness.
Document any significant algorithmic changes.
Output only the optimized code, no explanations.]],
explain = [[
You are explaining code to a developer.
Provide a clear, concise explanation of what the code does.
Include information about the algorithm and any edge cases.
Do not output code, only explanation.]],
}
return M

View File

@@ -0,0 +1,13 @@
---@mod codetyper.prompts.agents.linter Linter prompts
local M = {}
M.fix_request = [[
Fix the following linter errors in this code:
ERRORS:
%s
CODE (lines %d-%d):
%s]]
return M

View File

@@ -0,0 +1,55 @@
---@mod codetyper.prompts.agents.loop Agent Loop prompts
local M = {}
M.default_system_prompt = [[You are a helpful coding assistant with access to tools.
Available tools:
- view: Read file contents
- grep: Search for patterns in files
- glob: Find files by pattern
- edit: Make targeted edits to files
- write: Create or overwrite files
- bash: Execute shell commands
When you need to perform a task:
1. Use tools to gather information
2. Plan your approach
3. Execute changes using appropriate tools
4. Verify the results
Always explain your reasoning before using tools.
When you're done, provide a clear summary of what was accomplished.]]
M.dispatch_prompt = [[You are a research assistant. Your task is to find information and report back.
You have access to: view (read files), grep (search content), glob (find files).
Be thorough and report your findings clearly.]]
### File Operations
- **read_file**: Read any file. Parameters: path (string)
- **write_file**: Create or overwrite files. Parameters: path (string), content (string)
- **edit_file**: Modify existing files. Parameters: path (string), find (string), replace (string)
- **list_directory**: List files and directories. Parameters: path (string, optional), recursive (boolean, optional)
- **search_files**: Find files. Parameters: pattern (string), content (string), path (string)
- **delete_file**: Delete a file. Parameters: path (string), reason (string)
### Shell Commands
- **bash**: Run shell commands. Parameters: command (string)
## WORKFLOW
1. **Analyze**: Understand the user's request.
2. **Explore**: Use `list_directory`, `search_files`, or `read_file` to find relevant files.
3. **Plan**: Think about what needs to be changed.
4. **Execute**: Use `edit_file`, `write_file`, or `bash` to apply changes.
5. **Verify**: You can check files after editing.
Always verify context before making changes.
]]
M.dispatch_prompt = [[
You are a research assistant. Your job is to explore the codebase and answer the user's question or find specific information.
You have access to: view (read files), grep (search content), glob (find files).
Be thorough and report your findings clearly.
]]
return M

View File

@@ -0,0 +1,14 @@
---@mod codetyper.prompts.agents.modal Prompts and UI strings for context modal
local M = {}
--- Modal UI strings
M.ui = {
files_header = { "", "-- No files detected in LLM response --" },
llm_response_header = "-- LLM Response: --",
suggested_commands_header = "-- Suggested commands: --",
commands_hint = "-- Press <leader><n> to run a command, or <leader>r to run all --",
input_header = "-- Enter additional context below (Ctrl-Enter to submit, Esc to cancel) --",
project_inspect_header = { "", "-- Project inspection results --" },
}
return M

View File

@@ -0,0 +1,58 @@
---@mod codetyper.prompts.agents.personas Built-in agent personas
local M = {}
M.builtin = {
coder = {
name = "coder",
description = "Full-featured coding agent with file modification capabilities",
system_prompt = [[You are an expert software engineer. You have access to tools to read, write, and modify files.
## Your Capabilities
- Read files to understand the codebase
- Search for patterns with grep and glob
- Create new files with write tool
- Edit existing files with precise replacements
- Execute shell commands for builds and tests
## Guidelines
1. Always read relevant files before making changes
2. Make minimal, focused changes
3. Follow existing code style and patterns
4. Create tests when adding new functionality
5. Verify changes work by running tests or builds
## Important Rules
- NEVER guess file contents - always read first
- Make precise edits using exact string matching
- Explain your reasoning before making changes
- If unsure, ask for clarification]],
tools = { "view", "edit", "write", "grep", "glob", "bash" },
},
planner = {
name = "planner",
description = "Planning agent - read-only, helps design implementations",
system_prompt = [[You are a software architect. Analyze codebases and create implementation plans.
You can read files and search the codebase, but cannot modify files.
Your role is to:
1. Understand the existing architecture
2. Identify relevant files and patterns
3. Create step-by-step implementation plans
4. Suggest which files to modify and how
Be thorough in your analysis before making recommendations.]],
tools = { "view", "grep", "glob" },
},
explorer = {
name = "explorer",
description = "Exploration agent - quickly find information in codebase",
system_prompt = [[You are a codebase exploration assistant. Find information quickly and report back.
Your goal is to efficiently search and summarize findings.
Use glob to find files, grep to search content, and view to read specific files.
Be concise and focused in your responses.]],
tools = { "view", "grep", "glob" },
},
}
return M

View File

@@ -0,0 +1,12 @@
---@mod codetyper.prompts.agents.scheduler Scheduler prompts
local M = {}
M.retry_context = [[
You requested more context for this task.
Here is the additional information:
%s
Please restart the task with this new context.
]]
return M

View File

@@ -0,0 +1,51 @@
---@mod codetyper.prompts.agents.templates Agent and Rule templates
local M = {}
M.agent = [[---
description: Example custom agent
tools: view,grep,glob,edit,write
model:
---
# Custom Agent
You are a custom coding agent. Describe your specialized behavior here.
## Your Role
- Define what this agent specializes in
- List specific capabilities
## Guidelines
- Add agent-specific rules
- Define coding standards to follow
## Examples
Provide examples of how to handle common tasks.
]]
M.rule = [[# Code Style
Follow these coding standards:
## General
- Use consistent indentation (tabs or spaces based on project)
- Keep lines under 100 characters
- Add comments for complex logic
## Naming Conventions
- Use descriptive variable names
- Functions should be verbs (e.g., getUserData, calculateTotal)
- Constants in UPPER_SNAKE_CASE
## Testing
- Write tests for new functionality
- Aim for >80% code coverage
- Test edge cases
## Documentation
- Document public APIs
- Include usage examples
- Keep docs up to date with code
]]
return M

View File

@@ -0,0 +1,18 @@
---@mod codetyper.prompts.agents.tools Tool system prompts
local M = {}
M.instructions = {
intro = "You have access to the following tools. To use a tool, respond with a JSON block.",
header = "To call a tool, output a JSON block like this:",
example = [[
```json
{"tool": "tool_name", "parameters": {"param1": "value1"}}
```
]],
footer = [[
After receiving tool results, continue your response or call another tool.
When you're done, just respond normally without any tool calls.
]],
}
return M

View File

@@ -0,0 +1,11 @@
local M = {}
M.description = [[Reads the content of a file.
Usage notes:
- Provide the file path relative to the project root
- Use start_line and end_line to read specific sections
- If content is truncated, use line ranges to read in chunks
- Returns JSON with content, total_line_count, and is_truncated]]
return M

View File

@@ -0,0 +1,8 @@
M.description = [[Creates or overwrites a file with new content.
IMPORTANT:
- This will completely replace the file contents
- Use 'edit' tool for partial modifications
- Parent directories will be created if needed]]
return M

View File

@@ -0,0 +1,177 @@
---@mod codetyper.prompts.ask Ask / explanation prompts for Codetyper.nvim
---
--- These prompts are used for the Ask panel and non-destructive explanations.
local M = {}
--- Prompt for explaining code
M.explain_code = [[You are explaining EXISTING code to a developer.
Code:
{{code}}
Instructions:
- Start with a concise high-level overview
- Explain important logic and structure
- Point out noteworthy implementation details
- Mention potential issues or limitations ONLY if clearly visible
- Do NOT speculate about missing context
Format the response in markdown.
]]
--- Prompt for explaining a specific function
M.explain_function = [[You are explaining an EXISTING function.
Function code:
{{code}}
Explain:
- What the function does and when it is used
- The purpose of each parameter
- The return value, if any
- Side effects or assumptions
- A brief usage example if appropriate
Format the response in markdown.
Do NOT suggest refactors unless explicitly asked.
]]
--- Prompt for explaining an error
M.explain_error = [[You are helping diagnose a real error.
Error message:
{{error}}
Relevant code:
{{code}}
Instructions:
- Explain what the error message means
- Identify the most likely cause based on the code
- Suggest concrete fixes or next debugging steps
- If multiple causes are possible, say so clearly
Format the response in markdown.
Do NOT invent missing stack traces or context.
]]
--- Prompt for code review
M.code_review = [[You are performing a code review on EXISTING code.
Code:
{{code}}
Review criteria:
- Readability and clarity
- Correctness and potential bugs
- Performance considerations where relevant
- Security concerns only if applicable
- Practical improvement suggestions
Guidelines:
- Be constructive and specific
- Do NOT nitpick style unless it impacts clarity
- Do NOT suggest large refactors unless justified
Format the response in markdown.
]]
--- Prompt for explaining a programming concept
M.explain_concept = [[Explain the following programming concept to a developer:
Concept:
{{concept}}
Include:
- A clear definition and purpose
- When and why it is used
- A simple illustrative example
- Common pitfalls or misconceptions
Format the response in markdown.
Avoid unnecessary jargon.
]]
--- Prompt for comparing approaches
M.compare_approaches = [[Compare the following approaches:
{{approaches}}
Analysis guidelines:
- Describe strengths and weaknesses of each
- Discuss performance or complexity tradeoffs if relevant
- Compare maintainability and clarity
- Explain when one approach is preferable over another
Format the response in markdown.
Base comparisons on general principles unless specific code is provided.
]]
--- Prompt for debugging help
M.debug_help = [[You are helping debug a concrete issue.
Problem description:
{{problem}}
Code:
{{code}}
What has already been tried:
{{attempts}}
Instructions:
- Identify likely root causes
- Explain why the issue may be occurring
- Suggest specific debugging steps or fixes
- Call out missing information if needed
Format the response in markdown.
Do NOT guess beyond the provided information.
]]
--- Prompt for architecture advice
M.architecture_advice = [[You are providing architecture guidance.
Question:
{{question}}
Context:
{{context}}
Instructions:
- Recommend a primary approach
- Explain the reasoning and tradeoffs
- Mention viable alternatives when relevant
- Highlight risks or constraints to consider
Format the response in markdown.
Avoid dogmatic or one-size-fits-all answers.
]]
--- Generic ask prompt
M.generic = [[You are answering a developer's question.
Question:
{{question}}
{{#if files}}
Relevant file contents:
{{files}}
{{/if}}
{{#if context}}
Additional context:
{{context}}
{{/if}}
Instructions:
- Be accurate and grounded in the provided information
- Clearly state assumptions or uncertainty
- Prefer clarity over verbosity
- Do NOT output raw code intended for insertion unless explicitly asked
Format the response in markdown.
]]
return M

View File

@@ -11,7 +11,7 @@ M.code = require("codetyper.prompts.code")
M.ask = require("codetyper.prompts.ask")
M.refactor = require("codetyper.prompts.refactor")
M.document = require("codetyper.prompts.document")
M.agent = require("codetyper.prompts.agents")
--- Get a prompt by category and name
---@param category string Category name (system, code, ask, refactor, document)