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:
2026-03-18 23:05:26 -04:00
parent f110a3ed25
commit e57209a1f8
32 changed files with 1284 additions and 3414 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = {}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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({

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
},
}

View File

@@ -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

View File

@@ -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

View File

@@ -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