Adding autocomplete and copilot suggestions
This commit is contained in:
@@ -9,7 +9,20 @@ local M = {}
|
||||
---@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 = vim.split(diff_data.modified, "\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)
|
||||
@@ -59,7 +72,7 @@ function M.show_diff(diff_data, callback)
|
||||
col = col + half_width + 1,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
title = " MODIFIED [" .. diff_data.operation .. "] ",
|
||||
title = diff_data.operation == "delete" and " ⚠️ DELETE " or (" MODIFIED [" .. diff_data.operation .. "] "),
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
@@ -157,26 +170,52 @@ function M.show_diff(diff_data, callback)
|
||||
}, false, {})
|
||||
end
|
||||
|
||||
--- Show approval dialog for bash commands
|
||||
---@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(approved: boolean) Called with user decision
|
||||
---@param callback fun(result: BashApprovalResult) Called with user decision
|
||||
function M.show_bash_approval(command, callback)
|
||||
-- Create a simple floating window for bash approval
|
||||
local permissions = require("codetyper.agent.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 lines = {
|
||||
"",
|
||||
" BASH COMMAND APPROVAL",
|
||||
" " .. string.rep("-", 50),
|
||||
" " .. string.rep("─", 56),
|
||||
"",
|
||||
" Command:",
|
||||
" $ " .. command,
|
||||
"",
|
||||
" " .. string.rep("-", 50),
|
||||
" Press [y] or [Enter] to execute",
|
||||
" Press [n], [q], or [Esc] to cancel",
|
||||
"",
|
||||
}
|
||||
|
||||
local width = math.max(60, #command + 10)
|
||||
-- Add warning for dangerous commands
|
||||
if not perm_result.allowed and perm_result.reason ~= "Requires approval" then
|
||||
table.insert(lines, " ⚠️ WARNING: " .. perm_result.reason)
|
||||
table.insert(lines, "")
|
||||
end
|
||||
|
||||
table.insert(lines, " " .. string.rep("─", 56))
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, " [y] Allow once - Execute this command")
|
||||
table.insert(lines, " [s] Allow this session - Auto-allow until restart")
|
||||
table.insert(lines, " [a] Add to allow list - Always allow this command")
|
||||
table.insert(lines, " [n] Reject - Cancel execution")
|
||||
table.insert(lines, "")
|
||||
table.insert(lines, " " .. string.rep("─", 56))
|
||||
table.insert(lines, " Press key to choose | [q] or [Esc] to cancel")
|
||||
table.insert(lines, "")
|
||||
|
||||
local width = math.max(65, #command + 15)
|
||||
local height = #lines
|
||||
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
@@ -196,45 +235,84 @@ function M.show_bash_approval(command, callback)
|
||||
title_pos = "center",
|
||||
})
|
||||
|
||||
-- Apply some highlighting
|
||||
-- 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)
|
||||
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)
|
||||
callback({ approved = approved, permission_level = permission_level })
|
||||
end)
|
||||
end
|
||||
|
||||
local keymap_opts = { buffer = buf, noremap = true, silent = true, nowait = true }
|
||||
|
||||
-- Approve
|
||||
-- Allow once
|
||||
vim.keymap.set("n", "y", function()
|
||||
close_and_respond(true)
|
||||
close_and_respond(true, "allow")
|
||||
end, keymap_opts)
|
||||
vim.keymap.set("n", "<CR>", function()
|
||||
close_and_respond(true)
|
||||
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)
|
||||
close_and_respond(false, nil)
|
||||
end, keymap_opts)
|
||||
vim.keymap.set("n", "q", function()
|
||||
close_and_respond(false)
|
||||
close_and_respond(false, nil)
|
||||
end, keymap_opts)
|
||||
vim.keymap.set("n", "<Esc>", function()
|
||||
close_and_respond(false)
|
||||
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
|
||||
|
||||
@@ -27,6 +27,9 @@ function M.execute(tool_name, parameters, callback)
|
||||
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]
|
||||
@@ -156,6 +159,165 @@ function M.handle_bash(params, callback)
|
||||
})
|
||||
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
|
||||
table.insert(results, file:gsub(search_path .. "/", ""))
|
||||
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
|
||||
table.insert(grep_results, line:gsub(search_path .. "/", ""))
|
||||
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)
|
||||
@@ -164,6 +326,24 @@ function M.apply_change(diff_data, callback)
|
||||
-- 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)
|
||||
@@ -275,6 +455,22 @@ function M.reload_buffer_if_open(filepath)
|
||||
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
|
||||
|
||||
@@ -123,12 +123,14 @@ function M.agent_loop(context, callbacks)
|
||||
local config = codetyper.get_config()
|
||||
local parsed
|
||||
|
||||
if config.llm.provider == "claude" then
|
||||
-- Copilot uses Claude-like response format
|
||||
if config.llm.provider == "copilot" then
|
||||
parsed = parser.parse_claude_response(response)
|
||||
-- For Claude, preserve the original content array for proper tool_use handling
|
||||
table.insert(state.conversation, {
|
||||
role = "assistant",
|
||||
content = response.content, -- Keep original content blocks for Claude API
|
||||
content = parsed.text or "",
|
||||
tool_calls = parsed.tool_calls,
|
||||
_raw_content = response.content,
|
||||
})
|
||||
else
|
||||
-- For Ollama, response is the text directly
|
||||
@@ -200,9 +202,22 @@ function M.process_tool_calls(tool_calls, index, context, callbacks)
|
||||
show_fn = diff.show_diff
|
||||
end
|
||||
|
||||
show_fn(result.diff_data, function(approved)
|
||||
show_fn(result.diff_data, function(approval_result)
|
||||
-- Handle both old (boolean) and new (table) approval result formats
|
||||
local approved = type(approval_result) == "table" and approval_result.approved or approval_result
|
||||
local permission_level = type(approval_result) == "table" and approval_result.permission_level or nil
|
||||
|
||||
if approved then
|
||||
logs.tool(tool_call.name, "approved", "User approved")
|
||||
local log_msg = "User approved"
|
||||
if permission_level == "allow_session" then
|
||||
log_msg = "Allowed for session"
|
||||
elseif permission_level == "allow_list" then
|
||||
log_msg = "Added to allow list"
|
||||
elseif permission_level == "auto" then
|
||||
log_msg = "Auto-approved"
|
||||
end
|
||||
logs.tool(tool_call.name, "approved", log_msg)
|
||||
|
||||
-- Apply the change
|
||||
executor.apply_change(result.diff_data, function(apply_result)
|
||||
-- Store result for sending back to LLM
|
||||
@@ -261,8 +276,9 @@ function M.continue_with_results(context, callbacks)
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
|
||||
if config.llm.provider == "claude" then
|
||||
-- Claude format: tool_result blocks
|
||||
-- Copilot uses Claude-like format for tool results
|
||||
if config.llm.provider == "copilot" then
|
||||
-- Claude-style tool_result blocks
|
||||
local content = {}
|
||||
for _, result in ipairs(state.pending_tool_results) do
|
||||
table.insert(content, {
|
||||
|
||||
229
lua/codetyper/agent/permissions.lua
Normal file
229
lua/codetyper/agent/permissions.lua
Normal file
@@ -0,0 +1,229 @@
|
||||
---@mod codetyper.agent.permissions Permission manager for agent actions
|
||||
---
|
||||
--- Manages permissions for bash commands and file operations with
|
||||
--- allow, allow-session, allow-list, and reject options.
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class PermissionState
|
||||
---@field session_allowed table<string, boolean> Commands allowed for this session
|
||||
---@field allow_list table<string, boolean> Patterns always allowed
|
||||
---@field deny_list table<string, boolean> Patterns always denied
|
||||
|
||||
local state = {
|
||||
session_allowed = {},
|
||||
allow_list = {},
|
||||
deny_list = {},
|
||||
}
|
||||
|
||||
--- Dangerous command patterns that should never be auto-allowed
|
||||
local 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
|
||||
local SAFE_PATTERNS = {
|
||||
"^ls%s",
|
||||
"^ls$",
|
||||
"^cat%s",
|
||||
"^head%s",
|
||||
"^tail%s",
|
||||
"^grep%s",
|
||||
"^find%s",
|
||||
"^pwd$",
|
||||
"^echo%s",
|
||||
"^wc%s",
|
||||
"^which%s",
|
||||
"^type%s",
|
||||
"^file%s",
|
||||
"^stat%s",
|
||||
"^git%s+status",
|
||||
"^git%s+log",
|
||||
"^git%s+diff",
|
||||
"^git%s+branch",
|
||||
"^git%s+show",
|
||||
"^npm%s+list",
|
||||
"^npm%s+ls",
|
||||
"^npm%s+outdated",
|
||||
"^yarn%s+list",
|
||||
"^cargo%s+check",
|
||||
"^cargo%s+test",
|
||||
"^go%s+test",
|
||||
"^go%s+build",
|
||||
"^make%s+test",
|
||||
"^make%s+check",
|
||||
}
|
||||
|
||||
---@alias PermissionLevel "allow"|"allow_session"|"allow_list"|"reject"
|
||||
|
||||
---@class PermissionResult
|
||||
---@field allowed boolean Whether action is allowed
|
||||
---@field reason string Reason for the decision
|
||||
---@field auto boolean Whether this was an automatic decision
|
||||
|
||||
--- Check if a command matches a pattern
|
||||
---@param command string The command to check
|
||||
---@param pattern string The pattern to match
|
||||
---@return boolean
|
||||
local function matches_pattern(command, pattern)
|
||||
return command:match(pattern) ~= nil
|
||||
end
|
||||
|
||||
--- Check if command is dangerous
|
||||
---@param command string The command to check
|
||||
---@return boolean, string|nil dangerous, reason
|
||||
local function is_dangerous(command)
|
||||
for _, pattern in ipairs(DANGEROUS_PATTERNS) do
|
||||
if matches_pattern(command, pattern) then
|
||||
return true, "Matches dangerous pattern: " .. pattern
|
||||
end
|
||||
end
|
||||
return false, nil
|
||||
end
|
||||
|
||||
--- Check if command is safe
|
||||
---@param command string The command to check
|
||||
---@return boolean
|
||||
local function is_safe(command)
|
||||
for _, pattern in ipairs(SAFE_PATTERNS) do
|
||||
if matches_pattern(command, pattern) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Normalize command for comparison (trim, lowercase first word)
|
||||
---@param command string
|
||||
---@return string
|
||||
local function normalize_command(command)
|
||||
return vim.trim(command)
|
||||
end
|
||||
|
||||
--- Check permission for a bash command
|
||||
---@param command string The command to check
|
||||
---@return PermissionResult
|
||||
function M.check_bash_permission(command)
|
||||
local normalized = normalize_command(command)
|
||||
|
||||
-- Check deny list first
|
||||
for pattern, _ in pairs(state.deny_list) do
|
||||
if matches_pattern(normalized, pattern) then
|
||||
return {
|
||||
allowed = false,
|
||||
reason = "Command in deny list",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if command is dangerous
|
||||
local dangerous, reason = is_dangerous(normalized)
|
||||
if dangerous then
|
||||
return {
|
||||
allowed = false,
|
||||
reason = reason,
|
||||
auto = false, -- Require explicit approval for dangerous commands
|
||||
}
|
||||
end
|
||||
|
||||
-- Check session allowed
|
||||
if state.session_allowed[normalized] then
|
||||
return {
|
||||
allowed = true,
|
||||
reason = "Allowed for this session",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
|
||||
-- Check allow list patterns
|
||||
for pattern, _ in pairs(state.allow_list) do
|
||||
if matches_pattern(normalized, pattern) then
|
||||
return {
|
||||
allowed = true,
|
||||
reason = "Matches allow list pattern",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if command is inherently safe
|
||||
if is_safe(normalized) then
|
||||
return {
|
||||
allowed = true,
|
||||
reason = "Safe read-only command",
|
||||
auto = true,
|
||||
}
|
||||
end
|
||||
|
||||
-- Otherwise, require explicit permission
|
||||
return {
|
||||
allowed = false,
|
||||
reason = "Requires approval",
|
||||
auto = false,
|
||||
}
|
||||
end
|
||||
|
||||
--- Grant permission for a command
|
||||
---@param command string The command
|
||||
---@param level PermissionLevel The permission level
|
||||
function M.grant_permission(command, level)
|
||||
local normalized = normalize_command(command)
|
||||
|
||||
if level == "allow_session" then
|
||||
state.session_allowed[normalized] = true
|
||||
elseif level == "allow_list" then
|
||||
-- Add as pattern (escape special chars for exact match)
|
||||
local pattern = "^" .. vim.pesc(normalized) .. "$"
|
||||
state.allow_list[pattern] = true
|
||||
end
|
||||
end
|
||||
|
||||
--- Add a pattern to the allow list
|
||||
---@param pattern string Lua pattern to allow
|
||||
function M.add_to_allow_list(pattern)
|
||||
state.allow_list[pattern] = true
|
||||
end
|
||||
|
||||
--- Add a pattern to the deny list
|
||||
---@param pattern string Lua pattern to deny
|
||||
function M.add_to_deny_list(pattern)
|
||||
state.deny_list[pattern] = true
|
||||
end
|
||||
|
||||
--- Clear session permissions
|
||||
function M.clear_session()
|
||||
state.session_allowed = {}
|
||||
end
|
||||
|
||||
--- Reset all permissions
|
||||
function M.reset()
|
||||
state.session_allowed = {}
|
||||
state.allow_list = {}
|
||||
state.deny_list = {}
|
||||
end
|
||||
|
||||
--- Get current permission state (for debugging)
|
||||
---@return PermissionState
|
||||
function M.get_state()
|
||||
return vim.deepcopy(state)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -23,7 +23,7 @@ local M = {}
|
||||
---@field priority number Priority (1=high, 2=normal, 3=low)
|
||||
---@field status string "pending"|"processing"|"completed"|"escalated"|"cancelled"|"needs_context"|"failed"
|
||||
---@field attempt_count number Number of processing attempts
|
||||
---@field worker_type string|nil LLM provider used ("ollama"|"claude"|etc)
|
||||
---@field worker_type string|nil LLM provider used ("ollama"|"openai"|"gemini"|"copilot")
|
||||
---@field created_at number System time when created
|
||||
---@field intent Intent|nil Detected intent from prompt
|
||||
---@field scope ScopeInfo|nil Resolved scope (function/class/file)
|
||||
|
||||
@@ -28,7 +28,7 @@ local state = {
|
||||
max_concurrent = 2,
|
||||
completion_delay_ms = 100,
|
||||
apply_delay_ms = 5000, -- Wait before applying code
|
||||
remote_provider = "claude", -- Default fallback provider
|
||||
remote_provider = "copilot", -- Default fallback provider
|
||||
},
|
||||
}
|
||||
|
||||
@@ -90,9 +90,7 @@ local function get_remote_provider()
|
||||
-- If current provider is ollama, use configured remote
|
||||
if config.llm.provider == "ollama" then
|
||||
-- Check which remote provider is configured
|
||||
if config.llm.claude and config.llm.claude.api_key then
|
||||
return "claude"
|
||||
elseif config.llm.openai and config.llm.openai.api_key then
|
||||
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"
|
||||
@@ -120,7 +118,7 @@ local function get_primary_provider()
|
||||
return config.llm.provider
|
||||
end
|
||||
end
|
||||
return "claude"
|
||||
return "ollama"
|
||||
end
|
||||
|
||||
--- Retry event with additional context
|
||||
|
||||
@@ -81,6 +81,67 @@ M.definitions = {
|
||||
required = { "command" },
|
||||
},
|
||||
},
|
||||
|
||||
delete_file = {
|
||||
name = "delete_file",
|
||||
description = "Delete a file from the filesystem. Use with caution - requires explicit user approval.",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "Path to the file to delete",
|
||||
},
|
||||
reason = {
|
||||
type = "string",
|
||||
description = "Reason for deleting this file (shown to user for approval)",
|
||||
},
|
||||
},
|
||||
required = { "path", "reason" },
|
||||
},
|
||||
},
|
||||
|
||||
list_directory = {
|
||||
name = "list_directory",
|
||||
description = "List files and directories in a path. Use to explore project structure.",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = {
|
||||
type = "string",
|
||||
description = "Path to the directory to list (defaults to current directory)",
|
||||
},
|
||||
recursive = {
|
||||
type = "boolean",
|
||||
description = "Whether to list recursively (default: false, max depth: 3)",
|
||||
},
|
||||
},
|
||||
required = {},
|
||||
},
|
||||
},
|
||||
|
||||
search_files = {
|
||||
name = "search_files",
|
||||
description = "Search for files by name pattern or content. Use to find relevant files in the project.",
|
||||
parameters = {
|
||||
type = "object",
|
||||
properties = {
|
||||
pattern = {
|
||||
type = "string",
|
||||
description = "Glob pattern for file names (e.g., '*.lua', 'test_*.py')",
|
||||
},
|
||||
content = {
|
||||
type = "string",
|
||||
description = "Search for files containing this text",
|
||||
},
|
||||
path = {
|
||||
type = "string",
|
||||
description = "Directory to search in (defaults to project root)",
|
||||
},
|
||||
},
|
||||
required = {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
--- Convert tool definitions to Claude API format
|
||||
|
||||
@@ -35,14 +35,62 @@ local state = {
|
||||
local ns_chat = vim.api.nvim_create_namespace("codetyper_agent_chat")
|
||||
local ns_logs = vim.api.nvim_create_namespace("codetyper_agent_logs")
|
||||
|
||||
--- Fixed widths
|
||||
local CHAT_WIDTH = 300
|
||||
local LOGS_WIDTH = 50
|
||||
--- 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)
|
||||
@@ -479,7 +527,7 @@ function M.open()
|
||||
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, CHAT_WIDTH)
|
||||
vim.api.nvim_win_set_width(state.chat_win, get_panel_width())
|
||||
|
||||
-- Window options for chat
|
||||
vim.wo[state.chat_win].number = false
|
||||
@@ -592,6 +640,10 @@ function M.open()
|
||||
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
|
||||
@@ -603,7 +655,16 @@ function M.open()
|
||||
if ok then
|
||||
local config = codetyper.get_config()
|
||||
local provider = config.llm.provider
|
||||
local model = provider == "claude" and config.llm.claude.model or config.llm.ollama.model
|
||||
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
|
||||
|
||||
@@ -178,8 +178,7 @@ local active_workers = {}
|
||||
--- Default timeouts by provider type
|
||||
local default_timeouts = {
|
||||
ollama = 30000, -- 30s for local
|
||||
claude = 60000, -- 60s for remote
|
||||
openai = 60000,
|
||||
openai = 60000, -- 60s for remote
|
||||
gemini = 60000,
|
||||
copilot = 60000,
|
||||
}
|
||||
@@ -225,6 +224,54 @@ local function format_attached_files(attached_files)
|
||||
return table.concat(parts, "")
|
||||
end
|
||||
|
||||
--- Format indexed project context for inclusion in prompt
|
||||
---@param indexed_context table|nil
|
||||
---@return string
|
||||
local function format_indexed_context(indexed_context)
|
||||
if not indexed_context then
|
||||
return ""
|
||||
end
|
||||
|
||||
local parts = {}
|
||||
|
||||
-- Project type
|
||||
if indexed_context.project_type and indexed_context.project_type ~= "unknown" then
|
||||
table.insert(parts, "Project type: " .. indexed_context.project_type)
|
||||
end
|
||||
|
||||
-- Relevant symbols
|
||||
if indexed_context.relevant_symbols then
|
||||
local symbol_list = {}
|
||||
for symbol, files in pairs(indexed_context.relevant_symbols) do
|
||||
if #files > 0 then
|
||||
table.insert(symbol_list, symbol .. " (in " .. files[1] .. ")")
|
||||
end
|
||||
end
|
||||
if #symbol_list > 0 then
|
||||
table.insert(parts, "Relevant symbols: " .. table.concat(symbol_list, ", "))
|
||||
end
|
||||
end
|
||||
|
||||
-- Learned patterns
|
||||
if indexed_context.patterns and #indexed_context.patterns > 0 then
|
||||
local pattern_list = {}
|
||||
for i, p in ipairs(indexed_context.patterns) do
|
||||
if i <= 3 then
|
||||
table.insert(pattern_list, p.content or "")
|
||||
end
|
||||
end
|
||||
if #pattern_list > 0 then
|
||||
table.insert(parts, "Project conventions: " .. table.concat(pattern_list, "; "))
|
||||
end
|
||||
end
|
||||
|
||||
if #parts == 0 then
|
||||
return ""
|
||||
end
|
||||
|
||||
return "\n\n--- Project Context ---\n" .. table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
--- Build prompt for code generation
|
||||
---@param event table PromptEvent
|
||||
---@return string prompt
|
||||
@@ -245,9 +292,26 @@ local function build_prompt(event)
|
||||
|
||||
local filetype = vim.fn.fnamemodify(event.target_path or "", ":e")
|
||||
|
||||
-- Get indexed project context
|
||||
local indexed_context = nil
|
||||
local indexed_content = ""
|
||||
pcall(function()
|
||||
local indexer = require("codetyper.indexer")
|
||||
indexed_context = indexer.get_context_for({
|
||||
file = event.target_path,
|
||||
intent = event.intent,
|
||||
prompt = event.prompt_content,
|
||||
scope = event.scope_text,
|
||||
})
|
||||
indexed_content = format_indexed_context(indexed_context)
|
||||
end)
|
||||
|
||||
-- Format attached files
|
||||
local attached_content = format_attached_files(event.attached_files)
|
||||
|
||||
-- Combine attached files and indexed context
|
||||
local extra_context = attached_content .. indexed_content
|
||||
|
||||
-- Build context with scope information
|
||||
local context = {
|
||||
target_path = event.target_path,
|
||||
@@ -258,6 +322,7 @@ local function build_prompt(event)
|
||||
scope_range = event.scope_range,
|
||||
intent = event.intent,
|
||||
attached_files = event.attached_files,
|
||||
indexed_context = indexed_context,
|
||||
}
|
||||
|
||||
-- Build the actual prompt based on intent and scope
|
||||
@@ -296,7 +361,7 @@ Return ONLY the complete %s with implementation. No explanations, no duplicates.
|
||||
scope_type,
|
||||
filetype,
|
||||
event.scope_text,
|
||||
attached_content,
|
||||
extra_context,
|
||||
event.prompt_content,
|
||||
scope_type
|
||||
)
|
||||
@@ -317,7 +382,7 @@ Return the complete transformed %s. Output only code, no explanations.]],
|
||||
filetype,
|
||||
filetype,
|
||||
event.scope_text,
|
||||
attached_content,
|
||||
extra_context,
|
||||
event.prompt_content,
|
||||
scope_type
|
||||
)
|
||||
@@ -337,7 +402,7 @@ Output only the code to insert, no explanations.]],
|
||||
scope_name,
|
||||
filetype,
|
||||
event.scope_text,
|
||||
attached_content,
|
||||
extra_context,
|
||||
event.prompt_content
|
||||
)
|
||||
end
|
||||
@@ -357,7 +422,7 @@ Output only code, no explanations.]],
|
||||
filetype,
|
||||
filetype,
|
||||
target_content:sub(1, 4000), -- Limit context size
|
||||
attached_content,
|
||||
extra_context,
|
||||
event.prompt_content
|
||||
)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user