feat: adding multiple files
### Added
- **Smart Scope Resolution** — Tree-sitter + indentation context for
selections
- `resolve_selection_context()` in `scope/init.lua` handles partial
functions,
whole functions, multi-function spans, indent blocks, and whole-file
selections
- Enclosing function automatically sent as context when selecting code
inside one
- Whole-file selection (>=80% of lines) triggers project tree as
context
- Indentation-based fallback when Tree-sitter is unavailable
- **Explain-to-Document Intent** — "explain" prompts generate
documentation
- Detects prompts like "explain this", "tell me about", "what does",
"question"
- Generates documentation comments and inserts them above selected
code
- Shows notification if nothing is selected
- Updated intent action from "none" to "insert" for explain intent
- **Granular LLM Status Notifications** — Real-time progress reporting
- Inline virtual text and floating status window show current stage
- Stages: "Reading context...", "Searching index...", "Gathering
context...",
"Recalling patterns...", "Building prompt...", "Sending to
[provider]...",
"Processing response...", "Generating patch...", "Applying code..."
- `update_inline_status()` in `thinking_placeholder.lua`
- `update_stage()` in `thinking.lua`
- **Thinking Placeholder Positioning** — "Implementing..." appears above
selection
- Uses `virt_lines_above = true` on extmark at selection start line
- Dynamic status text updates during LLM processing
### Changed
- **Providers reduced to Copilot and Ollama only**
- Removed Claude, OpenAI, and Gemini provider integrations
- Deleted `llm/openai.lua` and `llm/gemini.lua`
- Cleaned `llm/init.lua`, `config/defaults.lua`, `types.lua`,
`credentials.lua`,
`cost/init.lua`, and `events/queue.lua` of all references
- `valid_providers` now only includes "copilot" and "ollama"
- **Removed timer-based delayed processing** — Prompts are processed
instantly
- Removed `timer` field, `timeout_ms`, and timer setup/cancellation
from `worker.lua`
- **Removed chat/agent/split window UI**
- Deleted `ui/chat.lua`, `windows.lua`, `ui/switcher.lua`
- Removed `CoderOpen`, `CoderClose`, `CoderToggle` commands
- Removed window management from `autocmds.lua`, `inject.lua`,
`executor.lua`
- Removed auto-open companion file logic
- **Commands removed from menu** (code retained with TODOs for
re-enabling)
- `CoderAddApiKey`, `CoderRemoveApiKey`, `CoderBrain`,
`CoderFeedback`,
`CoderMemories`, `CoderForget`, `CoderProcess`
- Subcommands `process`, `status`, `memories`, `forget`,
`llm-feedback-good`,
`llm-feedback-bad`, `add-api-key`, `remove-api-key` removed from
completion
### Fixed
- Fixed `patch.lua` syntax error — missing `if` wrapper around
SEARCH/REPLACE block
- Fixed `CoderModel` require path typo
(`codetyper.adapters.config.credentials`
→ `codetyper.config.credentials`)
- Fixed `thinking_placeholder` extmark placement appearing after
selection
instead of above it
This commit is contained in:
@@ -161,28 +161,12 @@ function M.setup()
|
||||
desc = "Set filetype for coder files",
|
||||
})
|
||||
|
||||
-- Auto-open split view when opening a coder file directly (e.g., from nvim-tree)
|
||||
vim.api.nvim_create_autocmd("BufEnter", {
|
||||
group = group,
|
||||
pattern = "*.codetyper/*",
|
||||
callback = function()
|
||||
-- Delay slightly to ensure buffer is fully loaded
|
||||
vim.defer_fn(function()
|
||||
M.auto_open_target_file()
|
||||
end, 50)
|
||||
end,
|
||||
desc = "Auto-open target file when coder file is opened",
|
||||
})
|
||||
|
||||
-- Cleanup on buffer close
|
||||
vim.api.nvim_create_autocmd("BufWipeout", {
|
||||
group = group,
|
||||
pattern = "*.codetyper/*",
|
||||
callback = function(ev)
|
||||
local window = require("codetyper.adapters.nvim.windows")
|
||||
if window.is_open() then
|
||||
window.close_split()
|
||||
end
|
||||
-- Clear processed prompts for this buffer
|
||||
local bufnr = ev.buf
|
||||
for key, _ in pairs(processed_prompts) do
|
||||
@@ -657,15 +641,16 @@ function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_che
|
||||
end
|
||||
end
|
||||
|
||||
-- Detect intent from prompt
|
||||
-- Detect intent from prompt (honor explicit override from transform-selection)
|
||||
local intent = intent_mod.detect(cleaned)
|
||||
|
||||
-- IMPORTANT: If prompt is inside a function/method and intent is "add",
|
||||
-- override to "complete" since we're completing the function body
|
||||
-- But NOT for coder files - they should use "add/append" by default
|
||||
if not is_from_coder_file and scope and (scope.type == "function" or scope.type == "method") then
|
||||
if prompt.intent_override then
|
||||
intent.action = prompt.intent_override.action or intent.action
|
||||
if prompt.intent_override.type then
|
||||
intent.type = prompt.intent_override.type
|
||||
end
|
||||
elseif not is_from_coder_file and scope and (scope.type == "function" or scope.type == "method") then
|
||||
if intent.type == "add" or intent.action == "insert" or intent.action == "append" then
|
||||
-- Override to complete the function instead of adding new code
|
||||
intent = {
|
||||
type = "complete",
|
||||
scope_hint = "function",
|
||||
@@ -686,6 +671,22 @@ function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_che
|
||||
}
|
||||
end
|
||||
|
||||
-- For whole-file selections, gather project tree context
|
||||
local project_context = nil
|
||||
if prompt.is_whole_file then
|
||||
pcall(function()
|
||||
local tree = require("codetyper.support.tree")
|
||||
local tree_log = tree.get_tree_log_path()
|
||||
if tree_log and vim.fn.filereadable(tree_log) == 1 then
|
||||
local tree_lines = vim.fn.readfile(tree_log)
|
||||
if tree_lines and #tree_lines > 0 then
|
||||
local tree_content = table.concat(tree_lines, "\n")
|
||||
project_context = tree_content:sub(1, 4000)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Determine priority based on intent
|
||||
local priority = 2
|
||||
if intent.type == "fix" or intent.type == "complete" then
|
||||
@@ -725,11 +726,15 @@ function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_che
|
||||
status = "pending",
|
||||
attempt_count = 0,
|
||||
intent = intent,
|
||||
intent_override = prompt.intent_override,
|
||||
scope = scope,
|
||||
scope_text = scope_text,
|
||||
scope_range = scope_range,
|
||||
attached_files = attached_files,
|
||||
injection_marks = injection_marks,
|
||||
injection_range = prompt.injection_range,
|
||||
is_whole_file = prompt.is_whole_file,
|
||||
project_context = project_context,
|
||||
})
|
||||
|
||||
local scope_info = scope
|
||||
@@ -843,98 +848,6 @@ end
|
||||
---@type table<number, boolean>
|
||||
local auto_opened_buffers = {}
|
||||
|
||||
--- Auto-open target file when a coder file is opened directly
|
||||
function M.auto_open_target_file()
|
||||
local window = require("codetyper.adapters.nvim.windows")
|
||||
|
||||
-- Skip if split is already open
|
||||
if window.is_open() then
|
||||
return
|
||||
end
|
||||
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
|
||||
-- Skip if we already handled this buffer
|
||||
if auto_opened_buffers[bufnr] then
|
||||
return
|
||||
end
|
||||
|
||||
local current_file = vim.fn.expand("%:p")
|
||||
|
||||
-- Skip empty paths
|
||||
if not current_file or current_file == "" then
|
||||
return
|
||||
end
|
||||
|
||||
-- Verify it's a coder file
|
||||
if not utils.is_coder_file(current_file) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Skip if we're in a special buffer (nvim-tree, etc.)
|
||||
local buftype = vim.bo[bufnr].buftype
|
||||
if buftype ~= "" then
|
||||
return
|
||||
end
|
||||
|
||||
-- Mark as handled
|
||||
auto_opened_buffers[bufnr] = true
|
||||
|
||||
-- Get the target file path
|
||||
local target_path = utils.get_target_path(current_file)
|
||||
|
||||
-- Check if target file exists
|
||||
if not utils.file_exists(target_path) then
|
||||
utils.notify("Target file not found: " .. vim.fn.fnamemodify(target_path, ":t"), vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
-- Get config with fallback defaults
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
|
||||
-- Fallback width if config not fully loaded (percentage, e.g., 25 = 25%)
|
||||
local width_pct = (config and config.window and config.window.width) or 25
|
||||
local width = math.ceil(vim.o.columns * (width_pct / 100))
|
||||
|
||||
-- Store current coder window
|
||||
local coder_win = vim.api.nvim_get_current_win()
|
||||
local coder_buf = bufnr
|
||||
|
||||
-- Open target file in a vertical split on the right
|
||||
local ok, err = pcall(function()
|
||||
vim.cmd("vsplit " .. vim.fn.fnameescape(target_path))
|
||||
end)
|
||||
|
||||
if not ok then
|
||||
utils.notify("Failed to open target file: " .. tostring(err), vim.log.levels.ERROR)
|
||||
auto_opened_buffers[bufnr] = nil -- Allow retry
|
||||
return
|
||||
end
|
||||
|
||||
-- Now we're in the target window (right side)
|
||||
local target_win = vim.api.nvim_get_current_win()
|
||||
local target_buf = vim.api.nvim_get_current_buf()
|
||||
|
||||
-- Set the coder window width (left side)
|
||||
pcall(vim.api.nvim_win_set_width, coder_win, width)
|
||||
|
||||
-- Update window module state
|
||||
window._coder_win = coder_win
|
||||
window._coder_buf = coder_buf
|
||||
window._target_win = target_win
|
||||
window._target_buf = target_buf
|
||||
|
||||
-- Set up window options for coder window
|
||||
pcall(function()
|
||||
vim.wo[coder_win].number = true
|
||||
vim.wo[coder_win].relativenumber = true
|
||||
vim.wo[coder_win].signcolumn = "yes"
|
||||
end)
|
||||
|
||||
utils.notify("Opened target: " .. vim.fn.fnamemodify(target_path, ":t"))
|
||||
end
|
||||
|
||||
--- Clear auto-opened tracking for a buffer
|
||||
---@param bufnr number Buffer number
|
||||
function M.clear_auto_opened(bufnr)
|
||||
|
||||
@@ -230,20 +230,8 @@ local function coder_cmd(args)
|
||||
["transform-selection"] = transform.cmd_transform_selection,
|
||||
["index-project"] = cmd_index_project,
|
||||
["index-status"] = cmd_index_status,
|
||||
memories = cmd_memories,
|
||||
forget = function(args)
|
||||
cmd_forget(args.fargs[2])
|
||||
end,
|
||||
-- LLM smart selection commands
|
||||
["llm-stats"] = cmd_llm_stats,
|
||||
["llm-feedback-good"] = function()
|
||||
cmd_llm_feedback(true)
|
||||
end,
|
||||
["llm-feedback-bad"] = function()
|
||||
cmd_llm_feedback(false)
|
||||
end,
|
||||
["llm-reset-stats"] = cmd_llm_reset_stats,
|
||||
-- Cost tracking commands
|
||||
["cost"] = function()
|
||||
local cost = require("codetyper.core.cost")
|
||||
cost.toggle()
|
||||
@@ -252,15 +240,6 @@ local function coder_cmd(args)
|
||||
local cost = require("codetyper.core.cost")
|
||||
cost.clear()
|
||||
end,
|
||||
-- Credentials management commands
|
||||
["add-api-key"] = function()
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
credentials.interactive_add()
|
||||
end,
|
||||
["remove-api-key"] = function()
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
credentials.interactive_remove()
|
||||
end,
|
||||
["credentials"] = function()
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
credentials.show_status()
|
||||
@@ -275,7 +254,6 @@ local function coder_cmd(args)
|
||||
local config = codetyper.get_config()
|
||||
local provider = config.llm.provider
|
||||
|
||||
-- Only available for Copilot provider
|
||||
if provider ~= "copilot" then
|
||||
utils.notify(
|
||||
"CoderModel is only available when using Copilot provider. Current: " .. provider:upper(),
|
||||
@@ -309,8 +287,6 @@ function M.setup()
|
||||
nargs = "?",
|
||||
complete = function()
|
||||
return {
|
||||
"process",
|
||||
"status",
|
||||
"tree",
|
||||
"tree-view",
|
||||
"reset",
|
||||
@@ -318,16 +294,10 @@ function M.setup()
|
||||
"transform-selection",
|
||||
"index-project",
|
||||
"index-status",
|
||||
"memories",
|
||||
"forget",
|
||||
"llm-stats",
|
||||
"llm-feedback-good",
|
||||
"llm-feedback-bad",
|
||||
"llm-reset-stats",
|
||||
"cost",
|
||||
"cost-clear",
|
||||
"add-api-key",
|
||||
"remove-api-key",
|
||||
"credentials",
|
||||
"switch-provider",
|
||||
"model",
|
||||
@@ -357,123 +327,9 @@ function M.setup()
|
||||
cmd_index_status()
|
||||
end, { desc = "Show project index status" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderMemories", function()
|
||||
cmd_memories()
|
||||
end, { desc = "Show learned memories" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderForget", function(opts)
|
||||
cmd_forget(opts.args ~= "" and opts.args or nil)
|
||||
end, {
|
||||
desc = "Clear memories (optionally matching pattern)",
|
||||
nargs = "?",
|
||||
})
|
||||
|
||||
-- Brain feedback command - teach the brain from your experience
|
||||
vim.api.nvim_create_user_command("CoderFeedback", function(opts)
|
||||
local brain = require("codetyper.core.memory")
|
||||
if not brain.is_initialized() then
|
||||
vim.notify("Brain not initialized", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local feedback_type = opts.args:lower()
|
||||
local current_file = vim.fn.expand("%:p")
|
||||
|
||||
if feedback_type == "good" or feedback_type == "accept" or feedback_type == "+" then
|
||||
-- Learn positive feedback
|
||||
brain.learn({
|
||||
type = "user_feedback",
|
||||
file = current_file,
|
||||
timestamp = os.time(),
|
||||
data = {
|
||||
feedback = "accepted",
|
||||
description = "User marked code as good/accepted",
|
||||
},
|
||||
})
|
||||
vim.notify("Brain: Learned positive feedback ✓", vim.log.levels.INFO)
|
||||
elseif feedback_type == "bad" or feedback_type == "reject" or feedback_type == "-" then
|
||||
-- Learn negative feedback
|
||||
brain.learn({
|
||||
type = "user_feedback",
|
||||
file = current_file,
|
||||
timestamp = os.time(),
|
||||
data = {
|
||||
feedback = "rejected",
|
||||
description = "User marked code as bad/rejected",
|
||||
},
|
||||
})
|
||||
vim.notify("Brain: Learned negative feedback ✗", vim.log.levels.INFO)
|
||||
elseif feedback_type == "stats" or feedback_type == "status" then
|
||||
-- Show brain stats
|
||||
local stats = brain.stats()
|
||||
local msg = string.format(
|
||||
"Brain Stats:\n• Nodes: %d\n• Edges: %d\n• Pending: %d\n• Deltas: %d",
|
||||
stats.node_count or 0,
|
||||
stats.edge_count or 0,
|
||||
stats.pending_changes or 0,
|
||||
stats.delta_count or 0
|
||||
)
|
||||
vim.notify(msg, vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify("Usage: CoderFeedback <good|bad|stats>", vim.log.levels.INFO)
|
||||
end
|
||||
end, {
|
||||
desc = "Give feedback to the brain (good/bad/stats)",
|
||||
nargs = "?",
|
||||
complete = function()
|
||||
return { "good", "bad", "stats" }
|
||||
end,
|
||||
})
|
||||
|
||||
-- Brain stats command
|
||||
vim.api.nvim_create_user_command("CoderBrain", function(opts)
|
||||
local brain = require("codetyper.core.memory")
|
||||
if not brain.is_initialized() then
|
||||
vim.notify("Brain not initialized", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local action = opts.args:lower()
|
||||
|
||||
if action == "stats" or action == "" then
|
||||
local stats = brain.stats()
|
||||
local lines = {
|
||||
"╭─────────────────────────────────╮",
|
||||
"│ CODETYPER BRAIN │",
|
||||
"╰─────────────────────────────────╯",
|
||||
"",
|
||||
string.format(" Nodes: %d", stats.node_count or 0),
|
||||
string.format(" Edges: %d", stats.edge_count or 0),
|
||||
string.format(" Deltas: %d", stats.delta_count or 0),
|
||||
string.format(" Pending: %d", stats.pending_changes or 0),
|
||||
"",
|
||||
" The more you use Codetyper,",
|
||||
" the smarter it becomes!",
|
||||
}
|
||||
vim.notify(table.concat(lines, "\n"), vim.log.levels.INFO)
|
||||
elseif action == "commit" then
|
||||
local hash = brain.commit("Manual commit")
|
||||
if hash then
|
||||
vim.notify("Brain: Committed changes (hash: " .. hash:sub(1, 8) .. ")", vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify("Brain: Nothing to commit", vim.log.levels.INFO)
|
||||
end
|
||||
elseif action == "flush" then
|
||||
brain.flush()
|
||||
vim.notify("Brain: Flushed to disk", vim.log.levels.INFO)
|
||||
elseif action == "prune" then
|
||||
local pruned = brain.prune()
|
||||
vim.notify("Brain: Pruned " .. pruned .. " low-value nodes", vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify("Usage: CoderBrain <stats|commit|flush|prune>", vim.log.levels.INFO)
|
||||
end
|
||||
end, {
|
||||
desc = "Brain management commands",
|
||||
nargs = "?",
|
||||
complete = function()
|
||||
return { "stats", "commit", "flush", "prune" }
|
||||
end,
|
||||
})
|
||||
-- TODO: re-enable CoderMemories, CoderForget when memory UI is reworked
|
||||
-- TODO: re-enable CoderFeedback when feedback loop is reworked
|
||||
-- TODO: re-enable CoderBrain when brain management UI is reworked
|
||||
|
||||
-- Cost estimation command
|
||||
vim.api.nvim_create_user_command("CoderCost", function()
|
||||
@@ -481,16 +337,7 @@ function M.setup()
|
||||
cost.toggle()
|
||||
end, { desc = "Show LLM cost estimation window" })
|
||||
|
||||
-- Credentials management commands
|
||||
vim.api.nvim_create_user_command("CoderAddApiKey", function()
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
credentials.interactive_add()
|
||||
end, { desc = "Add or update LLM provider API key" })
|
||||
|
||||
vim.api.nvim_create_user_command("CoderRemoveApiKey", function()
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
credentials.interactive_remove()
|
||||
end, { desc = "Remove LLM provider credentials" })
|
||||
-- TODO: re-enable CoderAddApiKey when multi-provider support returns
|
||||
|
||||
vim.api.nvim_create_user_command("CoderCredentials", function()
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
@@ -504,7 +351,7 @@ function M.setup()
|
||||
|
||||
-- Quick model switcher command (Copilot only)
|
||||
vim.api.nvim_create_user_command("CoderModel", function(opts)
|
||||
local credentials = require("codetyper.adapters.config.credentials")
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
local provider = config.llm.provider
|
||||
|
||||
@@ -1,907 +0,0 @@
|
||||
---@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
|
||||
@@ -1,44 +0,0 @@
|
||||
---@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
|
||||
@@ -22,6 +22,7 @@ local state = {
|
||||
throbber = nil,
|
||||
queue_listener_id = nil,
|
||||
timer = nil,
|
||||
stage_text = "Thinking...",
|
||||
}
|
||||
|
||||
local function get_ui_dimensions()
|
||||
@@ -84,9 +85,10 @@ local function update_display(icon, force)
|
||||
if count <= 0 and not force then
|
||||
return
|
||||
end
|
||||
local text = state.stage_text or "Thinking..."
|
||||
local line = (count <= 1)
|
||||
and (icon .. " Thinking...")
|
||||
or (icon .. " Thinking... (" .. tostring(count) .. " requests)")
|
||||
and (icon .. " " .. text)
|
||||
or (icon .. " " .. text .. " (" .. tostring(count) .. " requests)")
|
||||
vim.schedule(function()
|
||||
if state.buf_id and vim.api.nvim_buf_is_valid(state.buf_id) then
|
||||
vim.bo[state.buf_id].modifiable = true
|
||||
@@ -145,8 +147,15 @@ function M.ensure_shown()
|
||||
update_display(icon, true)
|
||||
end
|
||||
|
||||
--- Update the displayed stage text (e.g. "Reading context...", "Sending to LLM...").
|
||||
---@param text string
|
||||
function M.update_stage(text)
|
||||
state.stage_text = text
|
||||
end
|
||||
|
||||
--- Force close the thinking window (e.g. on VimLeavePre).
|
||||
function M.close()
|
||||
state.stage_text = "Thinking..."
|
||||
close_window()
|
||||
end
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
---@mod codetyper.window Window management for Codetyper.nvim
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
|
||||
---@type number|nil Current coder window ID
|
||||
M._coder_win = nil
|
||||
|
||||
---@type number|nil Current target window ID
|
||||
M._target_win = nil
|
||||
|
||||
---@type number|nil Current coder buffer ID
|
||||
M._coder_buf = nil
|
||||
|
||||
---@type number|nil Current target buffer ID
|
||||
M._target_buf = nil
|
||||
|
||||
--- Calculate window width based on configuration
|
||||
---@param config CoderConfig Plugin configuration
|
||||
---@return number Width in columns (minimum 30)
|
||||
local function calculate_width(config)
|
||||
local width = config.window.width
|
||||
if width <= 1 then
|
||||
-- Percentage of total width (1/4 of screen with minimum 30)
|
||||
return math.max(math.floor(vim.o.columns * width), 30)
|
||||
end
|
||||
return math.max(math.floor(width), 30)
|
||||
end
|
||||
|
||||
--- Open the coder split view
|
||||
---@param target_path string Path to the target file
|
||||
---@param coder_path string Path to the coder file
|
||||
---@return boolean Success status
|
||||
function M.open_split(target_path, coder_path)
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
|
||||
-- Ensure coder file exists, create if not
|
||||
if not utils.file_exists(coder_path) then
|
||||
local dir = vim.fn.fnamemodify(coder_path, ":h")
|
||||
utils.ensure_dir(dir)
|
||||
utils.write_file(coder_path, "")
|
||||
|
||||
-- Ensure gitignore is updated when creating a new coder file
|
||||
local gitignore = require("codetyper.support.gitignore")
|
||||
gitignore.ensure_ignored()
|
||||
end
|
||||
|
||||
-- Store current window as target window
|
||||
M._target_win = vim.api.nvim_get_current_win()
|
||||
M._target_buf = vim.api.nvim_get_current_buf()
|
||||
|
||||
-- Open target file if not already open
|
||||
if vim.fn.expand("%:p") ~= target_path then
|
||||
vim.cmd("edit " .. vim.fn.fnameescape(target_path))
|
||||
M._target_buf = vim.api.nvim_get_current_buf()
|
||||
end
|
||||
|
||||
-- Calculate width
|
||||
local width = calculate_width(config)
|
||||
|
||||
-- Create the coder split
|
||||
if config.window.position == "left" then
|
||||
vim.cmd("topleft vsplit " .. vim.fn.fnameescape(coder_path))
|
||||
else
|
||||
vim.cmd("botright vsplit " .. vim.fn.fnameescape(coder_path))
|
||||
end
|
||||
|
||||
-- Store coder window reference
|
||||
M._coder_win = vim.api.nvim_get_current_win()
|
||||
M._coder_buf = vim.api.nvim_get_current_buf()
|
||||
|
||||
-- Set coder window width
|
||||
vim.api.nvim_win_set_width(M._coder_win, width)
|
||||
|
||||
-- Set up window options for coder window
|
||||
vim.wo[M._coder_win].number = true
|
||||
vim.wo[M._coder_win].relativenumber = true
|
||||
vim.wo[M._coder_win].wrap = true
|
||||
vim.wo[M._coder_win].signcolumn = "yes"
|
||||
|
||||
-- Focus on target window (right side) by default
|
||||
if config.window.position == "left" then
|
||||
vim.api.nvim_set_current_win(M._target_win)
|
||||
end
|
||||
|
||||
utils.notify("Coder view opened: " .. vim.fn.fnamemodify(coder_path, ":t"))
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Close the coder split view
|
||||
---@return boolean Success status
|
||||
function M.close_split()
|
||||
if M._coder_win and vim.api.nvim_win_is_valid(M._coder_win) then
|
||||
vim.api.nvim_win_close(M._coder_win, false)
|
||||
M._coder_win = nil
|
||||
M._coder_buf = nil
|
||||
utils.notify("Coder view closed")
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Toggle the coder split view
|
||||
---@param target_path? string Path to the target file
|
||||
---@param coder_path? string Path to the coder file
|
||||
function M.toggle_split(target_path, coder_path)
|
||||
if M._coder_win and vim.api.nvim_win_is_valid(M._coder_win) then
|
||||
M.close_split()
|
||||
else
|
||||
if target_path and coder_path then
|
||||
M.open_split(target_path, coder_path)
|
||||
else
|
||||
utils.notify("No file specified for coder view", vim.log.levels.WARN)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if coder view is currently open
|
||||
---@return boolean
|
||||
function M.is_open()
|
||||
return M._coder_win ~= nil and vim.api.nvim_win_is_valid(M._coder_win)
|
||||
end
|
||||
|
||||
--- Get current coder window ID
|
||||
---@return number|nil
|
||||
function M.get_coder_win()
|
||||
return M._coder_win
|
||||
end
|
||||
|
||||
--- Get current target window ID
|
||||
---@return number|nil
|
||||
function M.get_target_win()
|
||||
return M._target_win
|
||||
end
|
||||
|
||||
--- Get current coder buffer ID
|
||||
---@return number|nil
|
||||
function M.get_coder_buf()
|
||||
return M._coder_buf
|
||||
end
|
||||
|
||||
--- Get current target buffer ID
|
||||
---@return number|nil
|
||||
function M.get_target_buf()
|
||||
return M._target_buf
|
||||
end
|
||||
|
||||
--- Focus on the coder window
|
||||
function M.focus_coder()
|
||||
if M._coder_win and vim.api.nvim_win_is_valid(M._coder_win) then
|
||||
vim.api.nvim_set_current_win(M._coder_win)
|
||||
end
|
||||
end
|
||||
|
||||
--- Focus on the target window
|
||||
function M.focus_target()
|
||||
if M._target_win and vim.api.nvim_win_is_valid(M._target_win) then
|
||||
vim.api.nvim_set_current_win(M._target_win)
|
||||
end
|
||||
end
|
||||
|
||||
--- Sync scroll between windows (optional feature)
|
||||
---@param enable boolean Enable or disable sync scroll
|
||||
function M.sync_scroll(enable)
|
||||
if not M.is_open() then
|
||||
return
|
||||
end
|
||||
|
||||
local value = enable and "scrollbind" or "noscrollbind"
|
||||
vim.wo[M._coder_win][value] = enable
|
||||
vim.wo[M._target_win][value] = enable
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -65,7 +65,7 @@ function M.save(data)
|
||||
end
|
||||
|
||||
--- Get API key for a provider
|
||||
---@param provider string Provider name (claude, openai, gemini, copilot, ollama)
|
||||
---@param provider string Provider name (copilot, ollama)
|
||||
---@return string|nil API key or nil if not found
|
||||
function M.get_api_key(provider)
|
||||
local data = M.load()
|
||||
@@ -167,14 +167,13 @@ function M.list_providers()
|
||||
local data = M.load()
|
||||
local result = {}
|
||||
|
||||
local all_providers = { "claude", "openai", "gemini", "copilot", "ollama" }
|
||||
local all_providers = { "copilot", "ollama" }
|
||||
|
||||
for _, provider in ipairs(all_providers) do
|
||||
local provider_data = data.providers and data.providers[provider]
|
||||
local has_stored_key = provider_data and provider_data.api_key and provider_data.api_key ~= ""
|
||||
local has_model = provider_data and provider_data.model and provider_data.model ~= ""
|
||||
|
||||
-- Check if configured from config or environment
|
||||
local configured_from_config = false
|
||||
local config_model = nil
|
||||
local ok, codetyper = pcall(require, "codetyper")
|
||||
@@ -184,14 +183,8 @@ function M.list_providers()
|
||||
local pc = config.llm[provider]
|
||||
config_model = pc.model
|
||||
|
||||
if provider == "claude" then
|
||||
configured_from_config = pc.api_key ~= nil or vim.env.ANTHROPIC_API_KEY ~= nil
|
||||
elseif provider == "openai" then
|
||||
configured_from_config = pc.api_key ~= nil or vim.env.OPENAI_API_KEY ~= nil
|
||||
elseif provider == "gemini" then
|
||||
configured_from_config = pc.api_key ~= nil or vim.env.GEMINI_API_KEY ~= nil
|
||||
elseif provider == "copilot" then
|
||||
configured_from_config = true -- Just needs copilot.lua
|
||||
if provider == "copilot" then
|
||||
configured_from_config = true
|
||||
elseif provider == "ollama" then
|
||||
configured_from_config = pc.host ~= nil
|
||||
end
|
||||
@@ -218,9 +211,6 @@ end
|
||||
|
||||
--- Default models for each provider
|
||||
M.default_models = {
|
||||
claude = "claude-sonnet-4-20250514",
|
||||
openai = "gpt-4o",
|
||||
gemini = "gemini-2.0-flash",
|
||||
copilot = "claude-sonnet-4",
|
||||
ollama = "deepseek-coder:6.7b",
|
||||
}
|
||||
@@ -276,18 +266,17 @@ function M.get_copilot_model_cost(model_name)
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Interactive command to add/update API key
|
||||
--- Interactive command to add/update configuration
|
||||
function M.interactive_add()
|
||||
local providers = { "claude", "openai", "gemini", "copilot", "ollama" }
|
||||
local providers = { "copilot", "ollama" }
|
||||
|
||||
-- Step 1: Select provider
|
||||
vim.ui.select(providers, {
|
||||
prompt = "Select LLM provider:",
|
||||
format_item = function(item)
|
||||
local display = item:sub(1, 1):upper() .. item:sub(2)
|
||||
local creds = M.load()
|
||||
local configured = creds.providers and creds.providers[item]
|
||||
if configured and (configured.api_key or item == "ollama") then
|
||||
if configured and (configured.configured or item == "ollama") then
|
||||
return display .. " [configured]"
|
||||
end
|
||||
return display
|
||||
@@ -297,36 +286,14 @@ function M.interactive_add()
|
||||
return
|
||||
end
|
||||
|
||||
-- Step 2: Get API key (skip for Ollama)
|
||||
if provider == "ollama" then
|
||||
M.interactive_ollama_config()
|
||||
else
|
||||
M.interactive_api_key(provider)
|
||||
elseif provider == "copilot" then
|
||||
M.interactive_copilot_config()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Interactive API key input
|
||||
---@param provider string Provider name
|
||||
function M.interactive_api_key(provider)
|
||||
-- Copilot uses OAuth from copilot.lua, no API key needed
|
||||
if provider == "copilot" then
|
||||
M.interactive_copilot_config()
|
||||
return
|
||||
end
|
||||
|
||||
local prompt = string.format("Enter %s API key (leave empty to skip): ", provider:upper())
|
||||
|
||||
vim.ui.input({ prompt = prompt }, function(api_key)
|
||||
if api_key == nil then
|
||||
return -- Cancelled
|
||||
end
|
||||
|
||||
-- Step 3: Get model
|
||||
M.interactive_model(provider, api_key)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Interactive Copilot configuration (no API key, uses OAuth)
|
||||
---@param silent? boolean If true, don't show the OAuth info message
|
||||
function M.interactive_copilot_config(silent)
|
||||
@@ -381,60 +348,6 @@ function M.interactive_copilot_config(silent)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Interactive model selection
|
||||
---@param provider string Provider name
|
||||
---@param api_key string|nil API key
|
||||
function M.interactive_model(provider, api_key)
|
||||
local default_model = M.default_models[provider] or ""
|
||||
local prompt = string.format("Enter model (default: %s): ", default_model)
|
||||
|
||||
vim.ui.input({ prompt = prompt, default = default_model }, function(model)
|
||||
if model == nil then
|
||||
return -- Cancelled
|
||||
end
|
||||
|
||||
-- Use default if empty
|
||||
if model == "" then
|
||||
model = default_model
|
||||
end
|
||||
|
||||
-- Save credentials
|
||||
local credentials = {
|
||||
model = model,
|
||||
}
|
||||
|
||||
if api_key and api_key ~= "" then
|
||||
credentials.api_key = api_key
|
||||
end
|
||||
|
||||
-- For OpenAI, also ask for custom endpoint
|
||||
if provider == "openai" then
|
||||
M.interactive_endpoint(provider, credentials)
|
||||
else
|
||||
M.save_and_notify(provider, credentials)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Interactive endpoint input for OpenAI-compatible providers
|
||||
---@param provider string Provider name
|
||||
---@param credentials table Current credentials
|
||||
function M.interactive_endpoint(provider, credentials)
|
||||
vim.ui.input({
|
||||
prompt = "Custom endpoint (leave empty for default OpenAI): ",
|
||||
}, function(endpoint)
|
||||
if endpoint == nil then
|
||||
return -- Cancelled
|
||||
end
|
||||
|
||||
if endpoint ~= "" then
|
||||
credentials.endpoint = endpoint
|
||||
end
|
||||
|
||||
M.save_and_notify(provider, credentials)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Interactive Ollama configuration
|
||||
function M.interactive_ollama_config()
|
||||
vim.ui.input({
|
||||
@@ -589,16 +502,14 @@ end
|
||||
---@param provider string Provider name
|
||||
---@return boolean configured, string|nil source
|
||||
local function is_provider_configured(provider)
|
||||
-- Check stored credentials first
|
||||
local data = M.load()
|
||||
local stored = data.providers and data.providers[provider]
|
||||
if stored then
|
||||
if stored.configured or stored.api_key or provider == "ollama" or provider == "copilot" then
|
||||
if stored.configured or provider == "ollama" or provider == "copilot" then
|
||||
return true, "stored"
|
||||
end
|
||||
end
|
||||
|
||||
-- Check codetyper config
|
||||
local ok, codetyper = pcall(require, "codetyper")
|
||||
if not ok then
|
||||
return false, nil
|
||||
@@ -614,24 +525,9 @@ local function is_provider_configured(provider)
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Check for API key in config or environment
|
||||
if provider == "claude" then
|
||||
if provider_config.api_key or vim.env.ANTHROPIC_API_KEY then
|
||||
return true, "config"
|
||||
end
|
||||
elseif provider == "openai" then
|
||||
if provider_config.api_key or vim.env.OPENAI_API_KEY then
|
||||
return true, "config"
|
||||
end
|
||||
elseif provider == "gemini" then
|
||||
if provider_config.api_key or vim.env.GEMINI_API_KEY then
|
||||
return true, "config"
|
||||
end
|
||||
elseif provider == "copilot" then
|
||||
-- Copilot just needs copilot.lua installed
|
||||
if provider == "copilot" then
|
||||
return true, "config"
|
||||
elseif provider == "ollama" then
|
||||
-- Ollama just needs host configured
|
||||
if provider_config.host then
|
||||
return true, "config"
|
||||
end
|
||||
@@ -642,7 +538,7 @@ end
|
||||
|
||||
--- Interactive switch provider
|
||||
function M.interactive_switch_provider()
|
||||
local all_providers = { "claude", "openai", "gemini", "copilot", "ollama" }
|
||||
local all_providers = { "copilot", "ollama" }
|
||||
local available = {}
|
||||
local sources = {}
|
||||
|
||||
|
||||
@@ -5,20 +5,11 @@ local M = {}
|
||||
---@type CoderConfig
|
||||
local defaults = {
|
||||
llm = {
|
||||
provider = "ollama", -- Options: "ollama", "openai", "gemini", "copilot"
|
||||
provider = "ollama", -- Options: "ollama", "copilot"
|
||||
ollama = {
|
||||
host = "http://localhost:11434",
|
||||
model = "deepseek-coder:6.7b",
|
||||
},
|
||||
openai = {
|
||||
api_key = nil, -- Will use OPENAI_API_KEY env var if nil
|
||||
model = "gpt-4o",
|
||||
endpoint = nil, -- Custom endpoint (Azure, OpenRouter, etc.)
|
||||
},
|
||||
gemini = {
|
||||
api_key = nil, -- Will use GEMINI_API_KEY env var if nil
|
||||
model = "gemini-2.0-flash",
|
||||
},
|
||||
copilot = {
|
||||
model = "claude-sonnet-4", -- Uses GitHub Copilot authentication
|
||||
},
|
||||
@@ -95,7 +86,7 @@ function M.validate(config)
|
||||
return false, "Missing LLM configuration"
|
||||
end
|
||||
|
||||
local valid_providers = { "ollama", "openai", "gemini", "copilot" }
|
||||
local valid_providers = { "ollama", "copilot" }
|
||||
local is_valid_provider = false
|
||||
for _, p in ipairs(valid_providers) do
|
||||
if config.llm.provider == p then
|
||||
@@ -108,21 +99,6 @@ function M.validate(config)
|
||||
return false, "Invalid LLM provider. Must be one of: " .. table.concat(valid_providers, ", ")
|
||||
end
|
||||
|
||||
-- Validate provider-specific configuration
|
||||
if config.llm.provider == "openai" then
|
||||
local api_key = config.llm.openai.api_key or vim.env.OPENAI_API_KEY
|
||||
if not api_key or api_key == "" then
|
||||
return false, "OpenAI API key not configured. Set llm.openai.api_key or OPENAI_API_KEY env var"
|
||||
end
|
||||
elseif config.llm.provider == "gemini" then
|
||||
local api_key = config.llm.gemini.api_key or vim.env.GEMINI_API_KEY
|
||||
if not api_key or api_key == "" then
|
||||
return false, "Gemini API key not configured. Set llm.gemini.api_key or GEMINI_API_KEY env var"
|
||||
end
|
||||
end
|
||||
-- Note: copilot uses OAuth from copilot.lua/copilot.vim, validated at runtime
|
||||
-- Note: ollama doesn't require API key, just host configuration
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -219,8 +219,7 @@ local function normalize_model(model)
|
||||
end
|
||||
|
||||
-- Handle common prefixes
|
||||
normalized = normalized:gsub("^openai/", "")
|
||||
normalized = normalized:gsub("^anthropic/", "")
|
||||
normalized = normalized:gsub("^copilot/", "")
|
||||
|
||||
-- Try exact match first
|
||||
if M.pricing[normalized] then
|
||||
|
||||
@@ -545,7 +545,9 @@ function M.apply(patch)
|
||||
-- Get filetype for smart injection
|
||||
local filetype = vim.fn.fnamemodify(patch.target_path or "", ":e")
|
||||
|
||||
if patch.use_search_replace and patch.search_replace_blocks and #patch.search_replace_blocks > 0 then
|
||||
-- Apply SEARCH/REPLACE blocks
|
||||
local search_replace = get_search_replace_module()
|
||||
local success, err = search_replace.apply_to_buffer(target_bufnr, patch.search_replace_blocks)
|
||||
|
||||
if success then
|
||||
|
||||
@@ -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"|"openai"|"gemini"|"copilot")
|
||||
---@field worker_type string|nil LLM provider used ("ollama"|"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)
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
---@mod codetyper.llm.gemini Google Gemini API client for Codetyper.nvim
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
local llm = require("codetyper.core.llm")
|
||||
|
||||
--- Gemini API endpoint
|
||||
local API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||
|
||||
--- Get API key from stored credentials, config, or environment
|
||||
---@return string|nil API key
|
||||
local function get_api_key()
|
||||
-- Priority: stored credentials > config > environment
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
local stored_key = credentials.get_api_key("gemini")
|
||||
if stored_key then
|
||||
return stored_key
|
||||
end
|
||||
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
return config.llm.gemini.api_key or vim.env.GEMINI_API_KEY
|
||||
end
|
||||
|
||||
--- Get model from stored credentials or config
|
||||
---@return string Model name
|
||||
local function get_model()
|
||||
-- Priority: stored credentials > config
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
local stored_model = credentials.get_model("gemini")
|
||||
if stored_model then
|
||||
return stored_model
|
||||
end
|
||||
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
return config.llm.gemini.model
|
||||
end
|
||||
|
||||
--- Build request body for Gemini API
|
||||
---@param prompt string User prompt
|
||||
---@param context table Context information
|
||||
---@return table Request body
|
||||
local function build_request_body(prompt, context)
|
||||
local system_prompt = llm.build_system_prompt(context)
|
||||
|
||||
return {
|
||||
systemInstruction = {
|
||||
role = "user",
|
||||
parts = { { text = system_prompt } },
|
||||
},
|
||||
contents = {
|
||||
{
|
||||
role = "user",
|
||||
parts = { { text = prompt } },
|
||||
},
|
||||
},
|
||||
generationConfig = {
|
||||
temperature = 0.2,
|
||||
maxOutputTokens = 4096,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
--- Make HTTP request to Gemini API
|
||||
---@param body table Request body
|
||||
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
|
||||
local function make_request(body, callback)
|
||||
local api_key = get_api_key()
|
||||
if not api_key then
|
||||
callback(nil, "Gemini API key not configured", nil)
|
||||
return
|
||||
end
|
||||
|
||||
local model = get_model()
|
||||
local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key
|
||||
local json_body = vim.json.encode(body)
|
||||
|
||||
local cmd = {
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
url,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-d",
|
||||
json_body,
|
||||
}
|
||||
|
||||
vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if not data or #data == 0 or (data[1] == "" and #data == 1) then
|
||||
return
|
||||
end
|
||||
|
||||
local response_text = table.concat(data, "\n")
|
||||
local ok, response = pcall(vim.json.decode, response_text)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(function()
|
||||
callback(nil, "Failed to parse Gemini response", nil)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
vim.schedule(function()
|
||||
callback(nil, response.error.message or "Gemini API error", nil)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Extract usage info
|
||||
local usage = {}
|
||||
if response.usageMetadata then
|
||||
usage.prompt_tokens = response.usageMetadata.promptTokenCount or 0
|
||||
usage.completion_tokens = response.usageMetadata.candidatesTokenCount or 0
|
||||
end
|
||||
|
||||
if response.candidates and response.candidates[1] then
|
||||
local candidate = response.candidates[1]
|
||||
if candidate.content and candidate.content.parts then
|
||||
local text_parts = {}
|
||||
for _, part in ipairs(candidate.content.parts) do
|
||||
if part.text then
|
||||
table.insert(text_parts, part.text)
|
||||
end
|
||||
end
|
||||
local full_text = table.concat(text_parts, "")
|
||||
local code = llm.extract_code(full_text)
|
||||
vim.schedule(function()
|
||||
callback(code, nil, usage)
|
||||
end)
|
||||
else
|
||||
vim.schedule(function()
|
||||
callback(nil, "No content in Gemini response", nil)
|
||||
end)
|
||||
end
|
||||
else
|
||||
vim.schedule(function()
|
||||
callback(nil, "No candidates in Gemini response", nil)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data and #data > 0 and data[1] ~= "" then
|
||||
vim.schedule(function()
|
||||
callback(nil, "Gemini API request failed: " .. table.concat(data, "\n"), nil)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
if code ~= 0 then
|
||||
vim.schedule(function()
|
||||
callback(nil, "Gemini API request failed with code: " .. code, nil)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
--- Generate code using Gemini API
|
||||
---@param prompt string The user's prompt
|
||||
---@param context table Context information
|
||||
---@param callback fun(response: string|nil, error: string|nil) Callback function
|
||||
function M.generate(prompt, context, callback)
|
||||
local body = build_request_body(prompt, context)
|
||||
utils.notify("Sending request to Gemini...", vim.log.levels.INFO)
|
||||
|
||||
make_request(body, function(response, err, usage)
|
||||
if err then
|
||||
utils.notify(err, vim.log.levels.ERROR)
|
||||
callback(nil, err)
|
||||
else
|
||||
utils.notify("Code generated successfully", vim.log.levels.INFO)
|
||||
callback(response, nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Check if Gemini is properly configured
|
||||
---@return boolean, string? Valid status and optional error message
|
||||
function M.validate()
|
||||
local api_key = get_api_key()
|
||||
if not api_key or api_key == "" then
|
||||
return false, "Gemini API key not configured"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -12,14 +12,10 @@ function M.get_client()
|
||||
|
||||
if config.llm.provider == "ollama" then
|
||||
return require("codetyper.core.llm.ollama")
|
||||
elseif config.llm.provider == "openai" then
|
||||
return require("codetyper.core.llm.openai")
|
||||
elseif config.llm.provider == "gemini" then
|
||||
return require("codetyper.core.llm.gemini")
|
||||
elseif config.llm.provider == "copilot" then
|
||||
return require("codetyper.core.llm.copilot")
|
||||
else
|
||||
error("Unknown LLM provider: " .. config.llm.provider)
|
||||
error("Unknown LLM provider: " .. config.llm.provider .. ". Supported: ollama, copilot")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
---@mod codetyper.llm.openai OpenAI API client for Codetyper.nvim
|
||||
|
||||
local M = {}
|
||||
|
||||
local utils = require("codetyper.support.utils")
|
||||
local llm = require("codetyper.core.llm")
|
||||
|
||||
--- OpenAI API endpoint
|
||||
local API_URL = "https://api.openai.com/v1/chat/completions"
|
||||
|
||||
--- Get API key from stored credentials, config, or environment
|
||||
---@return string|nil API key
|
||||
local function get_api_key()
|
||||
-- Priority: stored credentials > config > environment
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
local stored_key = credentials.get_api_key("openai")
|
||||
if stored_key then
|
||||
return stored_key
|
||||
end
|
||||
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
return config.llm.openai.api_key or vim.env.OPENAI_API_KEY
|
||||
end
|
||||
|
||||
--- Get model from stored credentials or config
|
||||
---@return string Model name
|
||||
local function get_model()
|
||||
-- Priority: stored credentials > config
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
local stored_model = credentials.get_model("openai")
|
||||
if stored_model then
|
||||
return stored_model
|
||||
end
|
||||
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
return config.llm.openai.model
|
||||
end
|
||||
|
||||
--- Get endpoint from stored credentials or config (allows custom endpoints like Azure, OpenRouter)
|
||||
---@return string API endpoint
|
||||
local function get_endpoint()
|
||||
-- Priority: stored credentials > config > default
|
||||
local credentials = require("codetyper.config.credentials")
|
||||
local stored_endpoint = credentials.get_endpoint("openai")
|
||||
if stored_endpoint then
|
||||
return stored_endpoint
|
||||
end
|
||||
|
||||
local codetyper = require("codetyper")
|
||||
local config = codetyper.get_config()
|
||||
return config.llm.openai.endpoint or API_URL
|
||||
end
|
||||
|
||||
--- Build request body for OpenAI API
|
||||
---@param prompt string User prompt
|
||||
---@param context table Context information
|
||||
---@return table Request body
|
||||
local function build_request_body(prompt, context)
|
||||
local system_prompt = llm.build_system_prompt(context)
|
||||
|
||||
return {
|
||||
model = get_model(),
|
||||
messages = {
|
||||
{ role = "system", content = system_prompt },
|
||||
{ role = "user", content = prompt },
|
||||
},
|
||||
max_tokens = 4096,
|
||||
temperature = 0.2,
|
||||
}
|
||||
end
|
||||
|
||||
--- Make HTTP request to OpenAI API
|
||||
---@param body table Request body
|
||||
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
|
||||
local function make_request(body, callback)
|
||||
local api_key = get_api_key()
|
||||
if not api_key then
|
||||
callback(nil, "OpenAI API key not configured", nil)
|
||||
return
|
||||
end
|
||||
|
||||
local endpoint = get_endpoint()
|
||||
local json_body = vim.json.encode(body)
|
||||
|
||||
local cmd = {
|
||||
"curl",
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
endpoint,
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-H",
|
||||
"Authorization: Bearer " .. api_key,
|
||||
"-d",
|
||||
json_body,
|
||||
}
|
||||
|
||||
vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
if not data or #data == 0 or (data[1] == "" and #data == 1) then
|
||||
return
|
||||
end
|
||||
|
||||
local response_text = table.concat(data, "\n")
|
||||
local ok, response = pcall(vim.json.decode, response_text)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(function()
|
||||
callback(nil, "Failed to parse OpenAI response", nil)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
vim.schedule(function()
|
||||
callback(nil, response.error.message or "OpenAI API error", nil)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- Extract usage info
|
||||
local usage = response.usage or {}
|
||||
|
||||
if response.choices and response.choices[1] and response.choices[1].message then
|
||||
local code = llm.extract_code(response.choices[1].message.content)
|
||||
vim.schedule(function()
|
||||
callback(code, nil, usage)
|
||||
end)
|
||||
else
|
||||
vim.schedule(function()
|
||||
callback(nil, "No content in OpenAI response", nil)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
if data and #data > 0 and data[1] ~= "" then
|
||||
vim.schedule(function()
|
||||
callback(nil, "OpenAI API request failed: " .. table.concat(data, "\n"), nil)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
if code ~= 0 then
|
||||
vim.schedule(function()
|
||||
callback(nil, "OpenAI API request failed with code: " .. code, nil)
|
||||
end)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
--- Generate code using OpenAI API
|
||||
---@param prompt string The user's prompt
|
||||
---@param context table Context information
|
||||
---@param callback fun(response: string|nil, error: string|nil) Callback function
|
||||
function M.generate(prompt, context, callback)
|
||||
local body = build_request_body(prompt, context)
|
||||
utils.notify("Sending request to OpenAI...", vim.log.levels.INFO)
|
||||
|
||||
make_request(body, function(response, err, usage)
|
||||
if err then
|
||||
utils.notify(err, vim.log.levels.ERROR)
|
||||
callback(nil, err)
|
||||
else
|
||||
utils.notify("Code generated successfully", vim.log.levels.INFO)
|
||||
callback(response, nil)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Check if OpenAI is properly configured
|
||||
---@return boolean, string? Valid status and optional error message
|
||||
function M.validate()
|
||||
local api_key = get_api_key()
|
||||
if not api_key or api_key == "" then
|
||||
return false, "OpenAI API key not configured"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -26,37 +26,21 @@ local function open_file_in_buffer(path, jump_to_line)
|
||||
end
|
||||
|
||||
vim.schedule(function()
|
||||
-- Find a suitable window (not the agent UI windows)
|
||||
-- Find a suitable window with a real file buffer
|
||||
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
|
||||
target_win = win
|
||||
break
|
||||
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
|
||||
|
||||
@@ -92,21 +92,13 @@ local function get_remote_provider()
|
||||
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
|
||||
return "copilot"
|
||||
end
|
||||
return config.llm.provider
|
||||
end
|
||||
end
|
||||
return state.config.remote_provider
|
||||
return "copilot"
|
||||
end
|
||||
|
||||
--- Get the primary provider (ollama if scout enabled, else configured)
|
||||
@@ -393,6 +385,14 @@ local function handle_worker_result(event, result)
|
||||
end
|
||||
|
||||
-- Good enough or final attempt - create patch
|
||||
pcall(function()
|
||||
local tp = require("codetyper.core.thinking_placeholder")
|
||||
tp.update_inline_status(event.id, "Generating patch...")
|
||||
local thinking = require("codetyper.adapters.nvim.ui.thinking")
|
||||
thinking.update_stage("Generating patch...")
|
||||
end)
|
||||
vim.notify("Generating patch...", vim.log.levels.INFO)
|
||||
|
||||
local p = patch.create_from_event(event, result.response, result.confidence)
|
||||
patch.queue_patch(p)
|
||||
|
||||
@@ -400,6 +400,14 @@ local function handle_worker_result(event, result)
|
||||
|
||||
-- Schedule patch application after delay (gives user time to review/cancel)
|
||||
local delay = state.config.apply_delay_ms or 5000
|
||||
pcall(function()
|
||||
local tp = require("codetyper.core.thinking_placeholder")
|
||||
tp.update_inline_status(event.id, "Applying code...")
|
||||
local thinking = require("codetyper.adapters.nvim.ui.thinking")
|
||||
thinking.update_stage("Applying code...")
|
||||
end)
|
||||
vim.notify("Applying code...", vim.log.levels.INFO)
|
||||
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---@mod codetyper.agent.worker Async LLM worker wrapper
|
||||
---@brief [[
|
||||
--- Wraps LLM clients with timeout handling and confidence scoring.
|
||||
--- Wraps LLM clients with confidence scoring.
|
||||
--- Provides unified interface for scheduler to dispatch work.
|
||||
---@brief ]]
|
||||
|
||||
@@ -23,15 +23,30 @@ local confidence = require("codetyper.core.llm.confidence")
|
||||
---@field id string Worker ID
|
||||
---@field event table PromptEvent being processed
|
||||
---@field worker_type string LLM provider type
|
||||
---@field status string "pending"|"running"|"completed"|"failed"|"timeout"
|
||||
---@field status string "pending"|"running"|"completed"|"failed"
|
||||
---@field start_time number Start timestamp
|
||||
---@field timeout_ms number Timeout in milliseconds
|
||||
---@field timer any Timeout timer handle
|
||||
---@field callback function Result callback
|
||||
|
||||
--- Worker ID counter
|
||||
local worker_counter = 0
|
||||
|
||||
--- Broadcast a stage update to inline placeholder, thinking window, and vim.notify.
|
||||
---@param event_id string|nil
|
||||
---@param text string Status text
|
||||
local function notify_stage(event_id, text)
|
||||
pcall(function()
|
||||
local tp = require("codetyper.core.thinking_placeholder")
|
||||
if event_id then
|
||||
tp.update_inline_status(event_id, text)
|
||||
end
|
||||
end)
|
||||
pcall(function()
|
||||
local thinking = require("codetyper.adapters.nvim.ui.thinking")
|
||||
thinking.update_stage(text)
|
||||
end)
|
||||
vim.notify(text, vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
--- Patterns that indicate LLM needs more context (must be near start of response)
|
||||
local context_needed_patterns = params.context_needed_patterns
|
||||
|
||||
@@ -205,8 +220,6 @@ end
|
||||
---@type table<string, Worker>
|
||||
local active_workers = {}
|
||||
|
||||
--- Default timeouts by provider type
|
||||
local default_timeouts = params.default_timeouts
|
||||
|
||||
--- Generate worker ID
|
||||
---@return string
|
||||
@@ -422,8 +435,10 @@ end
|
||||
---@return table context
|
||||
local function build_prompt(event)
|
||||
local intent_mod = require("codetyper.core.intent")
|
||||
local eid = event and event.id
|
||||
|
||||
notify_stage(eid, "Reading file...")
|
||||
|
||||
-- Get target file content for context
|
||||
local target_content = ""
|
||||
local target_lines = {}
|
||||
if event.target_path then
|
||||
@@ -438,7 +453,8 @@ local function build_prompt(event)
|
||||
|
||||
local filetype = vim.fn.fnamemodify(event.target_path or "", ":e")
|
||||
|
||||
-- Get indexed project context
|
||||
notify_stage(eid, "Searching index...")
|
||||
|
||||
local indexed_context = nil
|
||||
local indexed_content = ""
|
||||
pcall(function()
|
||||
@@ -452,21 +468,18 @@ local function build_prompt(event)
|
||||
indexed_content = format_indexed_context(indexed_context)
|
||||
end)
|
||||
|
||||
-- Format attached files
|
||||
local attached_content = format_attached_files(event.attached_files)
|
||||
|
||||
-- Get coder companion context (business logic, pseudo-code)
|
||||
notify_stage(eid, "Gathering context...")
|
||||
|
||||
local coder_context = get_coder_context(event.target_path)
|
||||
|
||||
-- Get brain memories - contextual recall based on current task
|
||||
notify_stage(eid, "Recalling patterns...")
|
||||
|
||||
local brain_context = ""
|
||||
pcall(function()
|
||||
local brain = require("codetyper.core.memory")
|
||||
if brain.is_initialized() then
|
||||
-- Query brain for relevant memories based on:
|
||||
-- 1. Current file (file-specific patterns)
|
||||
-- 2. Prompt content (semantic similarity)
|
||||
-- 3. Intent type (relevant past generations)
|
||||
local query_text = event.prompt_content or ""
|
||||
if event.scope and event.scope.name then
|
||||
query_text = event.scope.name .. " " .. query_text
|
||||
@@ -500,8 +513,16 @@ local function build_prompt(event)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Combine all context sources: brain memories first, then coder context, attached files, indexed
|
||||
local extra_context = brain_context .. coder_context .. attached_content .. indexed_content
|
||||
notify_stage(eid, "Building prompt...")
|
||||
|
||||
-- Include project tree context for whole-file selections
|
||||
local project_context = ""
|
||||
if event.is_whole_file and event.project_context then
|
||||
project_context = "\n\n--- Project Structure ---\n" .. event.project_context
|
||||
end
|
||||
|
||||
-- Combine all context sources: brain memories first, then coder context, attached files, indexed, project
|
||||
local extra_context = brain_context .. coder_context .. attached_content .. indexed_content .. project_context
|
||||
|
||||
-- Build context with scope information
|
||||
local context = {
|
||||
@@ -553,7 +574,7 @@ end thinking
|
||||
[[You are editing a %s file: %s
|
||||
|
||||
TASK: %s
|
||||
|
||||
%s
|
||||
FULL FILE:
|
||||
```%s
|
||||
%s
|
||||
@@ -564,6 +585,7 @@ Output ONLY the new code for that region (no markers, no explanations, no code f
|
||||
filetype,
|
||||
vim.fn.fnamemodify(event.target_path or "", ":t"),
|
||||
event.prompt_content,
|
||||
extra_context,
|
||||
filetype,
|
||||
file_content,
|
||||
start_line,
|
||||
@@ -706,7 +728,6 @@ function M.create(event, worker_type, callback)
|
||||
worker_type = worker_type,
|
||||
status = "pending",
|
||||
start_time = os.clock(),
|
||||
timeout_ms = default_timeouts[worker_type] or 60000,
|
||||
callback = callback,
|
||||
}
|
||||
|
||||
@@ -736,32 +757,9 @@ end
|
||||
---@param worker Worker
|
||||
function M.start(worker)
|
||||
worker.status = "running"
|
||||
local eid = worker.event and worker.event.id
|
||||
|
||||
-- Set up timeout
|
||||
worker.timer = vim.defer_fn(function()
|
||||
if worker.status == "running" then
|
||||
worker.status = "timeout"
|
||||
active_workers[worker.id] = nil
|
||||
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
logs.add({
|
||||
type = "warning",
|
||||
message = string.format("Worker %s timed out after %dms", worker.id, worker.timeout_ms),
|
||||
})
|
||||
end)
|
||||
|
||||
worker.callback({
|
||||
success = false,
|
||||
response = nil,
|
||||
error = "timeout",
|
||||
confidence = 0,
|
||||
confidence_breakdown = {},
|
||||
duration = (os.clock() - worker.start_time),
|
||||
worker_type = worker.worker_type,
|
||||
})
|
||||
end
|
||||
end, worker.timeout_ms)
|
||||
notify_stage(eid, "Reading context...")
|
||||
|
||||
local prompt, context = build_prompt(worker.event)
|
||||
|
||||
@@ -773,29 +771,22 @@ function M.start(worker)
|
||||
use_smart_selection = config.llm.smart_selection ~= false -- Default to true
|
||||
end)
|
||||
|
||||
local provider_label = worker.worker_type or "LLM"
|
||||
notify_stage(eid, "Sending to " .. provider_label .. "...")
|
||||
|
||||
-- Define the response handler
|
||||
local function handle_response(response, err, usage_or_metadata)
|
||||
-- Cancel timeout timer
|
||||
if worker.timer then
|
||||
pcall(function()
|
||||
if type(worker.timer) == "userdata" and worker.timer.stop then
|
||||
worker.timer:stop()
|
||||
end
|
||||
end)
|
||||
if worker.status ~= "running" then
|
||||
return -- Already cancelled
|
||||
end
|
||||
|
||||
if worker.status ~= "running" then
|
||||
return -- Already timed out or cancelled
|
||||
end
|
||||
notify_stage(eid, "Processing response...")
|
||||
|
||||
-- Extract usage from metadata if smart_generate was used
|
||||
local usage = usage_or_metadata
|
||||
if type(usage_or_metadata) == "table" and usage_or_metadata.provider then
|
||||
-- This is metadata from smart_generate
|
||||
usage = nil
|
||||
-- Update worker type to reflect actual provider used
|
||||
worker.worker_type = usage_or_metadata.provider
|
||||
-- Log if pondering occurred
|
||||
if usage_or_metadata.pondered then
|
||||
pcall(function()
|
||||
local logs = require("codetyper.adapters.nvim.ui.logs")
|
||||
@@ -819,7 +810,6 @@ function M.start(worker)
|
||||
local llm = require("codetyper.core.llm")
|
||||
llm.smart_generate(prompt, context, handle_response)
|
||||
else
|
||||
-- Get client and execute directly
|
||||
local client, client_err = get_client(worker.worker_type)
|
||||
if not client then
|
||||
M.complete(worker, nil, client_err)
|
||||
@@ -948,14 +938,6 @@ function M.cancel(worker_id)
|
||||
return false
|
||||
end
|
||||
|
||||
if worker.timer then
|
||||
pcall(function()
|
||||
if type(worker.timer) == "userdata" and worker.timer.stop then
|
||||
worker.timer:stop()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
worker.status = "cancelled"
|
||||
active_workers[worker_id] = nil
|
||||
|
||||
@@ -1012,11 +994,5 @@ function M.cancel_for_event(event_id)
|
||||
return cancelled
|
||||
end
|
||||
|
||||
--- Set timeout for worker type
|
||||
---@param worker_type string
|
||||
---@param timeout_ms number
|
||||
function M.set_timeout(worker_type, timeout_ms)
|
||||
default_timeouts[worker_type] = timeout_ms
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -428,4 +428,141 @@ function M.get_all_functions(bufnr)
|
||||
return functions
|
||||
end
|
||||
|
||||
--- Resolve enclosing context for a selection range.
|
||||
--- Handles partial selections inside a function, whole function selections,
|
||||
--- and selections that span across multiple functions.
|
||||
---@param bufnr number
|
||||
---@param sel_start number 1-indexed start line of selection
|
||||
---@param sel_end number 1-indexed end line of selection
|
||||
---@return table context { type: string, scopes: ScopeInfo[], expanded_start: number, expanded_end: number }
|
||||
function M.resolve_selection_context(bufnr, sel_start, sel_end)
|
||||
local all_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local total_lines = #all_lines
|
||||
|
||||
local scope_start = M.resolve_scope(bufnr, sel_start, 1)
|
||||
local scope_end = M.resolve_scope(bufnr, sel_end, 1)
|
||||
|
||||
local selected_lines = sel_end - sel_start + 1
|
||||
|
||||
if selected_lines >= (total_lines * 0.8) then
|
||||
return {
|
||||
type = "file",
|
||||
scopes = {},
|
||||
expanded_start = 1,
|
||||
expanded_end = total_lines,
|
||||
}
|
||||
end
|
||||
|
||||
-- Both ends resolve to the same function/method
|
||||
if scope_start.type ~= "file" and scope_end.type ~= "file"
|
||||
and scope_start.name == scope_end.name
|
||||
and scope_start.range.start_row == scope_end.range.start_row then
|
||||
|
||||
local fn_start = scope_start.range.start_row
|
||||
local fn_end = scope_start.range.end_row
|
||||
local fn_lines = fn_end - fn_start + 1
|
||||
local is_whole_fn = selected_lines >= (fn_lines * 0.85)
|
||||
|
||||
if is_whole_fn then
|
||||
return {
|
||||
type = "whole_function",
|
||||
scopes = { scope_start },
|
||||
expanded_start = fn_start,
|
||||
expanded_end = fn_end,
|
||||
}
|
||||
else
|
||||
return {
|
||||
type = "partial_function",
|
||||
scopes = { scope_start },
|
||||
expanded_start = sel_start,
|
||||
expanded_end = sel_end,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- Selection spans across multiple functions or one end is file-level
|
||||
local affected = {}
|
||||
local functions = M.get_all_functions(bufnr)
|
||||
|
||||
if #functions > 0 then
|
||||
for _, fn in ipairs(functions) do
|
||||
local fn_start = fn.range.start_row
|
||||
local fn_end = fn.range.end_row
|
||||
if fn_end >= sel_start and fn_start <= sel_end then
|
||||
table.insert(affected, fn)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #affected > 0 then
|
||||
local exp_start = sel_start
|
||||
local exp_end = sel_end
|
||||
for _, fn in ipairs(affected) do
|
||||
exp_start = math.min(exp_start, fn.range.start_row)
|
||||
exp_end = math.max(exp_end, fn.range.end_row)
|
||||
end
|
||||
return {
|
||||
type = "multi_function",
|
||||
scopes = affected,
|
||||
expanded_start = exp_start,
|
||||
expanded_end = exp_end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Indentation-based fallback: walk outward to find the enclosing block
|
||||
local base_indent = math.huge
|
||||
for i = sel_start, math.min(sel_end, total_lines) do
|
||||
local line = all_lines[i]
|
||||
if line and not line:match("^%s*$") then
|
||||
local indent = #(line:match("^(%s*)") or "")
|
||||
base_indent = math.min(base_indent, indent)
|
||||
end
|
||||
end
|
||||
if base_indent == math.huge then
|
||||
base_indent = 0
|
||||
end
|
||||
|
||||
local block_start = sel_start
|
||||
for i = sel_start - 1, 1, -1 do
|
||||
local line = all_lines[i]
|
||||
if line and not line:match("^%s*$") then
|
||||
local indent = #(line:match("^(%s*)") or "")
|
||||
if indent < base_indent then
|
||||
block_start = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local block_end = sel_end
|
||||
for i = sel_end + 1, total_lines do
|
||||
local line = all_lines[i]
|
||||
if line and not line:match("^%s*$") then
|
||||
local indent = #(line:match("^(%s*)") or "")
|
||||
if indent < base_indent then
|
||||
block_end = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local block_lines = {}
|
||||
for i = block_start, math.min(block_end, total_lines) do
|
||||
table.insert(block_lines, all_lines[i])
|
||||
end
|
||||
|
||||
return {
|
||||
type = "indent_block",
|
||||
scopes = {{
|
||||
type = "block",
|
||||
node_type = "indentation",
|
||||
range = { start_row = block_start, end_row = block_end },
|
||||
text = table.concat(block_lines, "\n"),
|
||||
name = nil,
|
||||
}},
|
||||
expanded_start = block_start,
|
||||
expanded_end = block_end,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -112,7 +112,7 @@ function M.remove_on_failure(event_id)
|
||||
M.clear(event_id)
|
||||
end
|
||||
|
||||
--- 99-style: show "⠋ Thinking..." as virtual text at the line above the selection (no buffer change).
|
||||
--- 99-style: show "⠋ Implementing..." as virtual text at the line above the selection (no buffer change).
|
||||
--- Use for inline requests where we must not insert placeholder (e.g. SEARCH/REPLACE).
|
||||
---@param event table PromptEvent with id, range, target_path
|
||||
function M.start_inline(event)
|
||||
@@ -132,11 +132,11 @@ function M.start_inline(event)
|
||||
if target_bufnr <= 0 or not vim.api.nvim_buf_is_valid(target_bufnr) then
|
||||
return
|
||||
end
|
||||
-- Mark at line above range (99: mark_above_range). If start is line 1 (0-indexed 0), use row 0.
|
||||
local start_row_0 = math.max(0, range.start_line - 2) -- 1-based start_line -> 0-based, then one line up
|
||||
local start_row_0 = math.max(0, range.start_line - 1)
|
||||
local col = 0
|
||||
local extmark_id = vim.api.nvim_buf_set_extmark(target_bufnr, ns_inline, start_row_0, col, {
|
||||
virt_lines = { { { " Implementing", "Comment" } } },
|
||||
virt_lines_above = true,
|
||||
})
|
||||
local Throbber = require("codetyper.adapters.nvim.ui.throbber")
|
||||
local throb = Throbber.new(function(icon)
|
||||
@@ -147,9 +147,11 @@ function M.start_inline(event)
|
||||
if not ent.bufnr or not vim.api.nvim_buf_is_valid(ent.bufnr) then
|
||||
return
|
||||
end
|
||||
local text = ent.status_text or "Implementing"
|
||||
local ok = pcall(vim.api.nvim_buf_set_extmark, ent.bufnr, ns_inline, start_row_0, col, {
|
||||
id = ent.extmark_id,
|
||||
virt_lines = { { { icon .. " Implementing", "Comment" } } },
|
||||
virt_lines = { { { icon .. " " .. text, "Comment" } } },
|
||||
virt_lines_above = true,
|
||||
})
|
||||
if not ok then
|
||||
M.clear_inline(event.id)
|
||||
@@ -162,10 +164,21 @@ function M.start_inline(event)
|
||||
throbber = throb,
|
||||
start_row_0 = start_row_0,
|
||||
col = col,
|
||||
status_text = "Implementing",
|
||||
}
|
||||
throb:start()
|
||||
end
|
||||
|
||||
--- Update the inline status text for a running event.
|
||||
---@param event_id string
|
||||
---@param text string New status text (e.g. "Reading context...", "Sending to LLM...")
|
||||
function M.update_inline_status(event_id, text)
|
||||
local ent = inline_status[event_id]
|
||||
if ent then
|
||||
ent.status_text = text
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear 99-style inline virtual text (call when worker completes).
|
||||
---@param event_id string
|
||||
function M.clear_inline(event_id)
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
local M = {}
|
||||
|
||||
local EXPLAIN_PATTERNS = {
|
||||
"explain", "what does", "what is", "how does", "how is",
|
||||
"why does", "why is", "tell me", "walk through", "understand",
|
||||
"question", "what's this", "what this", "about this", "help me understand",
|
||||
}
|
||||
|
||||
---@param input string
|
||||
---@return boolean
|
||||
local function is_explain_intent(input)
|
||||
local lower = input:lower()
|
||||
for _, pat in ipairs(EXPLAIN_PATTERNS) do
|
||||
if lower:find(pat, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Return editor dimensions (from UI, like 99 plugin)
|
||||
---@return number width
|
||||
---@return number height
|
||||
@@ -152,39 +170,143 @@ function M.cmd_transform_selection()
|
||||
|
||||
local submitted = false
|
||||
|
||||
-- Resolve enclosing context for the selection (handles all cases:
|
||||
-- partial inside function, whole function, spanning multiple functions, indentation fallback)
|
||||
local scope_mod = require("codetyper.core.scope")
|
||||
local sel_context = nil
|
||||
local is_whole_file = false
|
||||
|
||||
if has_selection and selection_data then
|
||||
sel_context = scope_mod.resolve_selection_context(bufnr, start_line, end_line)
|
||||
is_whole_file = sel_context.type == "file"
|
||||
|
||||
-- Expand injection range to cover full enclosing scopes when needed
|
||||
if sel_context.type == "whole_function" or sel_context.type == "multi_function" then
|
||||
injection_range.start_line = sel_context.expanded_start
|
||||
injection_range.end_line = sel_context.expanded_end
|
||||
start_line = sel_context.expanded_start
|
||||
end_line = sel_context.expanded_end
|
||||
-- Re-read the expanded selection text
|
||||
local exp_lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
|
||||
selection_text = table.concat(exp_lines, "\n")
|
||||
end
|
||||
end
|
||||
|
||||
local function submit_prompt()
|
||||
if not prompt_buf or not vim.api.nvim_buf_is_valid(prompt_buf) then
|
||||
close_prompt()
|
||||
return
|
||||
end
|
||||
submitted = true
|
||||
local lines = vim.api.nvim_buf_get_lines(prompt_buf, 0, -1, false)
|
||||
local input = table.concat(lines, "\n"):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local lines_input = vim.api.nvim_buf_get_lines(prompt_buf, 0, -1, false)
|
||||
local input = table.concat(lines_input, "\n"):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
close_prompt()
|
||||
if input == "" then
|
||||
logger.info("commands", "User cancelled prompt input")
|
||||
return
|
||||
end
|
||||
|
||||
local is_explain = is_explain_intent(input)
|
||||
|
||||
-- Explain intent requires a selection — notify and bail if none
|
||||
if is_explain and not has_selection then
|
||||
vim.notify("Nothing selected to explain — select code first", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
|
||||
local content
|
||||
if has_selection then
|
||||
content = input .. "\n\nCode to replace (replace this code):\n" .. selection_text
|
||||
local doc_injection_range = injection_range
|
||||
local doc_intent_override = has_selection and { action = "replace" } or (is_cursor_insert and { action = "insert" } or nil)
|
||||
|
||||
if is_explain and has_selection and sel_context then
|
||||
-- Build a prompt that asks the LLM to generate documentation comments only
|
||||
local ft = vim.bo[bufnr].filetype or "text"
|
||||
local context_block = ""
|
||||
if sel_context.type == "partial_function" and #sel_context.scopes > 0 then
|
||||
local scope = sel_context.scopes[1]
|
||||
context_block = string.format(
|
||||
"\n\nEnclosing %s \"%s\":\n```%s\n%s\n```",
|
||||
scope.type, scope.name or "anonymous", ft, scope.text
|
||||
)
|
||||
elseif sel_context.type == "multi_function" and #sel_context.scopes > 0 then
|
||||
local parts = {}
|
||||
for _, s in ipairs(sel_context.scopes) do
|
||||
table.insert(parts, string.format("-- %s \"%s\":\n%s", s.type, s.name or "anonymous", s.text))
|
||||
end
|
||||
context_block = "\n\nRelated scopes:\n```" .. ft .. "\n" .. table.concat(parts, "\n\n") .. "\n```"
|
||||
elseif sel_context.type == "indent_block" and #sel_context.scopes > 0 then
|
||||
context_block = string.format(
|
||||
"\n\nEnclosing block:\n```%s\n%s\n```",
|
||||
ft, sel_context.scopes[1].text
|
||||
)
|
||||
end
|
||||
|
||||
content = string.format(
|
||||
"%s\n\nGenerate documentation comments for the following %s code. "
|
||||
.. "Output ONLY the comment block using the correct comment syntax for %s. "
|
||||
.. "Do NOT include the code itself.%s\n\nCode to document:\n```%s\n%s\n```",
|
||||
input, ft, ft, context_block, ft, selection_text
|
||||
)
|
||||
|
||||
-- Insert above the selection instead of replacing it
|
||||
doc_injection_range = { start_line = start_line, end_line = start_line }
|
||||
doc_intent_override = { action = "insert", type = "explain" }
|
||||
|
||||
elseif has_selection and sel_context then
|
||||
if sel_context.type == "partial_function" and #sel_context.scopes > 0 then
|
||||
local scope = sel_context.scopes[1]
|
||||
content = string.format(
|
||||
"%s\n\nEnclosing %s \"%s\" (lines %d-%d):\n```\n%s\n```\n\nSelected code to modify (lines %d-%d):\n%s",
|
||||
input,
|
||||
scope.type,
|
||||
scope.name or "anonymous",
|
||||
scope.range.start_row, scope.range.end_row,
|
||||
scope.text,
|
||||
start_line, end_line,
|
||||
selection_text
|
||||
)
|
||||
elseif sel_context.type == "multi_function" and #sel_context.scopes > 0 then
|
||||
local scope_descs = {}
|
||||
for _, s in ipairs(sel_context.scopes) do
|
||||
table.insert(scope_descs, string.format("- %s \"%s\" (lines %d-%d)",
|
||||
s.type, s.name or "anonymous", s.range.start_row, s.range.end_row))
|
||||
end
|
||||
content = string.format(
|
||||
"%s\n\nAffected scopes:\n%s\n\nCode to replace (lines %d-%d):\n%s",
|
||||
input,
|
||||
table.concat(scope_descs, "\n"),
|
||||
start_line, end_line,
|
||||
selection_text
|
||||
)
|
||||
elseif sel_context.type == "indent_block" and #sel_context.scopes > 0 then
|
||||
local block = sel_context.scopes[1]
|
||||
content = string.format(
|
||||
"%s\n\nEnclosing block (lines %d-%d):\n```\n%s\n```\n\nSelected code to modify (lines %d-%d):\n%s",
|
||||
input,
|
||||
block.range.start_row, block.range.end_row,
|
||||
block.text,
|
||||
start_line, end_line,
|
||||
selection_text
|
||||
)
|
||||
else
|
||||
content = input .. "\n\nCode to replace (replace this code):\n" .. selection_text
|
||||
end
|
||||
elseif is_cursor_insert then
|
||||
content = "Insert at line " .. start_line .. ":\n" .. input
|
||||
else
|
||||
content = input
|
||||
end
|
||||
-- Pass captured range so scheduler/patch know where to inject the generated code
|
||||
|
||||
local prompt = {
|
||||
content = content,
|
||||
start_line = injection_range.start_line,
|
||||
end_line = injection_range.end_line,
|
||||
start_line = doc_injection_range.start_line,
|
||||
end_line = doc_injection_range.end_line,
|
||||
start_col = 1,
|
||||
end_col = 1,
|
||||
user_prompt = input,
|
||||
-- Explicit injection range (same as start_line/end_line) for downstream
|
||||
injection_range = injection_range,
|
||||
-- When there's a selection, force replace; when no selection, insert at cursor
|
||||
intent_override = has_selection and { action = "replace" } or (is_cursor_insert and { action = "insert" } or nil),
|
||||
injection_range = doc_injection_range,
|
||||
intent_override = doc_intent_override,
|
||||
is_whole_file = is_whole_file,
|
||||
}
|
||||
local autocmds = require("codetyper.adapters.nvim.autocmds")
|
||||
autocmds.process_single_prompt(bufnr, prompt, filepath, true)
|
||||
|
||||
@@ -9,22 +9,16 @@ local utils = require("codetyper.support.utils")
|
||||
---@param code string Generated code
|
||||
---@param prompt_type string Type of prompt (refactor, add, document, etc.)
|
||||
function M.inject_code(target_path, code, prompt_type)
|
||||
local window = require("codetyper.adapters.nvim.windows")
|
||||
|
||||
-- Normalize the target path
|
||||
target_path = vim.fn.fnamemodify(target_path, ":p")
|
||||
|
||||
-- Get target buffer
|
||||
local target_buf = window.get_target_buf()
|
||||
|
||||
if not target_buf or not vim.api.nvim_buf_is_valid(target_buf) then
|
||||
-- Try to find buffer by path
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local buf_name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ":p")
|
||||
if buf_name == target_path then
|
||||
target_buf = buf
|
||||
break
|
||||
end
|
||||
-- Try to find buffer by path
|
||||
local target_buf = nil
|
||||
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local buf_name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ":p")
|
||||
if buf_name == target_path then
|
||||
target_buf = buf
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
@@ -144,16 +138,13 @@ end
|
||||
function M.inject_add(bufnr, code)
|
||||
local lines = vim.split(code, "\n", { plain = true })
|
||||
|
||||
-- Get cursor position in target window
|
||||
local window = require("codetyper.adapters.nvim.windows")
|
||||
local target_win = window.get_target_win()
|
||||
|
||||
-- Try to find a window displaying this buffer to get cursor position
|
||||
local insert_line
|
||||
if target_win and vim.api.nvim_win_is_valid(target_win) then
|
||||
local cursor = vim.api.nvim_win_get_cursor(target_win)
|
||||
local wins = vim.fn.win_findbuf(bufnr)
|
||||
if #wins > 0 then
|
||||
local cursor = vim.api.nvim_win_get_cursor(wins[1])
|
||||
insert_line = cursor[1]
|
||||
else
|
||||
-- Append at end
|
||||
insert_line = vim.api.nvim_buf_line_count(bufnr)
|
||||
end
|
||||
|
||||
|
||||
@@ -125,19 +125,27 @@ M.intent_patterns = {
|
||||
priority = 2,
|
||||
},
|
||||
|
||||
-- Explain: provide explanation (no code change)
|
||||
-- Explain: generate documentation for selected code
|
||||
explain = {
|
||||
patterns = {
|
||||
"explain",
|
||||
"what does",
|
||||
"what is",
|
||||
"how does",
|
||||
"why",
|
||||
"describe",
|
||||
"how is",
|
||||
"why does",
|
||||
"why is",
|
||||
"tell me",
|
||||
"walk through",
|
||||
"understand",
|
||||
"question",
|
||||
"what's this",
|
||||
"what this",
|
||||
"about this",
|
||||
"help me understand",
|
||||
},
|
||||
scope_hint = "function",
|
||||
action = "none",
|
||||
action = "insert",
|
||||
priority = 4,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -19,12 +19,8 @@ M.context_needed_patterns = {
|
||||
|
||||
--- 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
|
||||
|
||||
@@ -44,10 +44,11 @@ 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.]],
|
||||
You are documenting code by adding documentation comments above it.
|
||||
Generate ONLY the documentation comment block (using the correct comment syntax for the file's language).
|
||||
Include: a brief description of what the code does, parameter types and descriptions, return type and description, and any important notes about edge cases or side effects.
|
||||
Do NOT include the code itself in your output — only the documentation comment block.
|
||||
Output nothing else.]],
|
||||
}
|
||||
|
||||
return M
|
||||
|
||||
@@ -7,25 +7,14 @@
|
||||
---@field auto_gitignore boolean Auto-manage .gitignore
|
||||
|
||||
---@class LLMConfig
|
||||
---@field provider "ollama" | "openai" | "gemini" | "copilot" The LLM provider to use
|
||||
---@field provider "ollama" | "copilot" The LLM provider to use
|
||||
---@field ollama OllamaConfig Ollama-specific configuration
|
||||
---@field openai OpenAIConfig OpenAI-specific configuration
|
||||
---@field gemini GeminiConfig Gemini-specific configuration
|
||||
---@field copilot CopilotConfig Copilot-specific configuration
|
||||
|
||||
---@class OllamaConfig
|
||||
---@field host string Ollama host URL
|
||||
---@field model string Ollama model to use
|
||||
|
||||
---@class OpenAIConfig
|
||||
---@field api_key string | nil OpenAI API key (or env var OPENAI_API_KEY)
|
||||
---@field model string OpenAI model to use
|
||||
---@field endpoint string | nil Custom endpoint (Azure, OpenRouter, etc.)
|
||||
|
||||
---@class GeminiConfig
|
||||
---@field api_key string | nil Gemini API key (or env var GEMINI_API_KEY)
|
||||
---@field model string Gemini model to use
|
||||
|
||||
---@class CopilotConfig
|
||||
---@field model string Copilot model to use
|
||||
|
||||
|
||||
Reference in New Issue
Block a user