Adding the functionallity and refactoring

This commit is contained in:
2026-02-17 00:15:40 -05:00
parent 4463a8144d
commit 0a1429a823
8 changed files with 423 additions and 793 deletions

View File

@@ -322,22 +322,12 @@ local function create_injection_marks(target_bufnr, range)
end_line = start_line
end
local marks = require("codetyper.core.marks")
local end_line_content = vim.api.nvim_buf_get_lines(
target_bufnr,
end_line - 1,
end_line,
false
)
local end_line_content = vim.api.nvim_buf_get_lines(target_bufnr, end_line - 1, end_line, false)
local end_col_0 = 0
if end_line_content and end_line_content[1] then
end_col_0 = #end_line_content[1]
end
local start_mark, end_mark = marks.mark_range(
target_bufnr,
start_line,
end_line,
end_col_0
)
local start_mark, end_mark = marks.mark_range(target_bufnr, start_line, end_line, end_col_0)
if not start_mark.id or not end_mark.id then
return nil
end
@@ -540,7 +530,9 @@ function M.check_for_closed_prompt()
end
-- Use captured injection range when provided, else prompt.start_line/end_line
local raw_start = (prompt.injection_range and prompt.injection_range.start_line) or prompt.start_line or 1
local raw_start = (prompt.injection_range and prompt.injection_range.start_line)
or prompt.start_line
or 1
local raw_end = (prompt.injection_range and prompt.injection_range.end_line) or prompt.end_line or 1
local tc = vim.api.nvim_buf_line_count(target_bufnr)
tc = math.max(1, tc)

View File

@@ -2,222 +2,8 @@
local M = {}
local transform = require("codetyper.core.transform")
local utils = require("codetyper.support.utils")
local window = require("codetyper.adapters.nvim.windows")
--- Open coder view for current file or select one
---@param opts? table Command options
local function cmd_open(opts)
opts = opts or {}
local current_file = vim.fn.expand("%:p")
-- If no file is open, prompt user to select one
if current_file == "" or vim.bo.buftype ~= "" then
-- Use telescope or vim.ui.select to pick a file
if pcall(require, "telescope") then
require("telescope.builtin").find_files({
prompt_title = "Select file for Coder",
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 target_path = selection.path or selection[1]
local coder_path = utils.get_coder_path(target_path)
window.open_split(target_path, coder_path)
end
end)
return true
end,
})
else
-- Fallback to input prompt
vim.ui.input({ prompt = "Enter file path: " }, function(input)
if input and input ~= "" then
local target_path = vim.fn.fnamemodify(input, ":p")
local coder_path = utils.get_coder_path(target_path)
window.open_split(target_path, coder_path)
end
end)
end
return
end
local target_path, coder_path
-- Check if current file is a coder file
if utils.is_coder_file(current_file) then
coder_path = current_file
target_path = utils.get_target_path(current_file)
else
target_path = current_file
coder_path = utils.get_coder_path(current_file)
end
window.open_split(target_path, coder_path)
end
--- Close coder view
local function cmd_close()
window.close_split()
end
--- Toggle coder view
local function cmd_toggle()
local current_file = vim.fn.expand("%:p")
if current_file == "" then
utils.notify("No file in current buffer", vim.log.levels.WARN)
return
end
local target_path, coder_path
if utils.is_coder_file(current_file) then
coder_path = current_file
target_path = utils.get_target_path(current_file)
else
target_path = current_file
coder_path = utils.get_coder_path(current_file)
end
window.toggle_split(target_path, coder_path)
end
--- Return editor dimensions (from UI, like 99 plugin)
---@return number width
---@return number height
local function get_ui_dimensions()
local ui = vim.api.nvim_list_uis()[1]
if ui then
return ui.width, ui.height
end
return vim.o.columns, vim.o.lines
end
--- Centered floating window config for prompt (2/3 width, 1/3 height)
---@return table { width, height, row, col, border }
local function create_centered_window()
local width, height = get_ui_dimensions()
local win_width = math.floor(width * 2 / 3)
local win_height = math.floor(height / 3)
return {
width = win_width,
height = win_height,
row = math.floor((height - win_height) / 2),
col = math.floor((width - win_width) / 2),
border = "rounded",
}
end
--- Build enhanced user prompt with context
---@param clean_prompt string The cleaned user prompt
---@param context table Context information
---@return string Enhanced prompt
local function build_user_prompt(clean_prompt, context)
local enhanced = "TASK: " .. clean_prompt .. "\n\n"
enhanced = enhanced .. "REQUIREMENTS:\n"
enhanced = enhanced .. "- Generate ONLY " .. (context.language or "code") .. " code\n"
enhanced = enhanced .. "- NO markdown code blocks (no ```)\n"
enhanced = enhanced .. "- NO explanations or comments about what you did\n"
enhanced = enhanced .. "- Match the coding style of the existing file exactly\n"
enhanced = enhanced .. "- Output must be ready to insert directly into the file\n"
return enhanced
end
--- Process prompt at cursor and generate code
local function cmd_process()
local parser = require("codetyper.parser")
local llm = require("codetyper.core.llm")
local bufnr = vim.api.nvim_get_current_buf()
local current_file = vim.fn.expand("%:p")
if not utils.is_coder_file(current_file) then
utils.notify("Not a coder file. Use *.coder.* files", vim.log.levels.WARN)
return
end
local prompt = parser.get_last_prompt(bufnr)
if not prompt then
utils.notify("No prompt found. Use /@ your prompt @/", vim.log.levels.WARN)
return
end
local target_path = utils.get_target_path(current_file)
local prompt_type = parser.detect_prompt_type(prompt.content)
local context = llm.build_context(target_path, prompt_type)
local clean_prompt = parser.clean_prompt(prompt.content)
-- Build enhanced prompt with explicit instructions
local enhanced_prompt = build_user_prompt(clean_prompt, context)
utils.notify("Processing: " .. clean_prompt:sub(1, 50) .. "...", vim.log.levels.INFO)
llm.generate(enhanced_prompt, context, function(response, err)
if err then
utils.notify("Generation failed: " .. err, vim.log.levels.ERROR)
return
end
if response then
-- Inject code into target file
local inject = require("codetyper.inject")
inject.inject_code(target_path, response, prompt_type)
utils.notify("Code generated and injected!", vim.log.levels.INFO)
end
end)
end
--- Show plugin status
local function cmd_status()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local tree = require("codetyper.support.tree")
local stats = tree.get_stats()
local status = {
"Codetyper.nvim Status",
"====================",
"",
"Provider: " .. config.llm.provider,
}
if config.llm.provider == "ollama" then
table.insert(status, "Ollama Host: " .. config.llm.ollama.host)
table.insert(status, "Ollama Model: " .. config.llm.ollama.model)
elseif config.llm.provider == "openai" then
local has_key = (config.llm.openai.api_key or vim.env.OPENAI_API_KEY) ~= nil
table.insert(status, "OpenAI API Key: " .. (has_key and "configured" or "NOT SET"))
table.insert(status, "OpenAI Model: " .. config.llm.openai.model)
elseif config.llm.provider == "gemini" then
local has_key = (config.llm.gemini.api_key or vim.env.GEMINI_API_KEY) ~= nil
table.insert(status, "Gemini API Key: " .. (has_key and "configured" or "NOT SET"))
table.insert(status, "Gemini Model: " .. config.llm.gemini.model)
elseif config.llm.provider == "copilot" then
table.insert(status, "Copilot Model: " .. config.llm.copilot.model)
end
table.insert(status, "")
table.insert(status, "Window Position: " .. config.window.position)
table.insert(status, "Window Width: " .. tostring(config.window.width * 100) .. "%")
table.insert(status, "")
table.insert(status, "View Open: " .. (window.is_open() and "yes" or "no"))
table.insert(status, "")
table.insert(status, "Project Stats:")
table.insert(status, " Files: " .. stats.files)
table.insert(status, " Directories: " .. stats.directories)
table.insert(status, " Tree Log: " .. (tree.get_tree_log_path() or "N/A"))
utils.notify(table.concat(status, "\n"))
end
--- Refresh tree.log manually
local function cmd_tree()
@@ -260,248 +46,6 @@ local function cmd_gitignore()
gitignore.force_update()
end
--- Switch focus between coder and target windows
local function cmd_focus()
if not window.is_open() then
utils.notify("Coder view not open", vim.log.levels.WARN)
return
end
local current_win = vim.api.nvim_get_current_win()
if current_win == window.get_coder_win() then
window.focus_target()
else
window.focus_coder()
end
end
--- Transform prompts within a line range (for visual selection)
--- Uses the same processing logic as automatic mode for consistent results
---@param start_line number Start line (1-indexed)
---@param end_line number End line (1-indexed)
local function cmd_transform_range(start_line, end_line)
local parser = require("codetyper.parser")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
if filepath == "" then
utils.notify("No file in current buffer", vim.log.levels.WARN)
return
end
-- Find all prompts in the current buffer
local all_prompts = parser.find_prompts_in_buffer(bufnr)
-- Filter prompts that are within the selected range
local prompts = {}
for _, prompt in ipairs(all_prompts) do
if prompt.start_line >= start_line and prompt.end_line <= end_line then
table.insert(prompts, prompt)
end
end
if #prompts == 0 then
utils.notify(
"No /@ @/ tags found in selection (lines " .. start_line .. "-" .. end_line .. ")",
vim.log.levels.INFO
)
return
end
utils.notify("Transforming " .. #prompts .. " prompt(s)...", vim.log.levels.INFO)
-- Process each prompt using the same logic as automatic mode (skip processed check for manual mode)
for _, prompt in ipairs(prompts) do
autocmds.process_single_prompt(bufnr, prompt, filepath, true)
end
end
--- Get visual selection text
---@return string|nil selected_text The selected text or nil
local function get_visual_selection()
local mode = vim.api.nvim_get_mode().mode
-- Third argument must be a Vim dictionary; empty Lua table can be treated as list
local opts = vim.empty_dict()
-- \22 is an escaped version of <c-v>
if mode == "v" or mode == "V" or mode == "\22" then
opts = { type = mode }
end
return vim.fn.getregion(vim.fn.getpos("v"), vim.fn.getpos("."), opts)
end
--- Transform visual selection with custom prompt input
--- Opens input window for prompt, processes selection on confirm.
--- When nothing is selected (e.g. from Normal mode), only the prompt is requested.
local function cmd_transform_selection()
local logger = require("codetyper.support.logger")
logger.func_entry("commands", "cmd_transform_selection", {})
-- Get visual selection (getregion returns a table of lines); may be empty in Normal mode
local selection = get_visual_selection()
local selection_text = type(selection) == "table" and table.concat(selection, "\n") or tostring(selection or "")
local has_selection = selection_text and #selection_text >= 4
if has_selection then
logger.debug(
"commands",
"Visual selection: " .. selection_text:sub(1, 100) .. (#selection_text > 100 and "..." or "")
)
logger.info("commands", "Selected " .. #selection_text .. " characters, opening prompt input...")
else
logger.info("commands", "No selection, opening prompt input only...")
end
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
local line_count = vim.api.nvim_buf_line_count(bufnr)
line_count = math.max(1, line_count)
-- Range for injection: selection, or whole file when no selection
local start_line, end_line
if has_selection then
start_line = vim.fn.line("'<")
-- Derive end_line from selection content so range matches selected lines (''>' can be wrong after UI changes)
local selection_lines = type(selection) == "table" and #selection or #vim.split(selection_text, "\n", { plain = true })
selection_lines = math.max(1, selection_lines)
end_line = math.min(start_line + selection_lines - 1, line_count)
else
-- No selection: apply to whole file so the LLM works on the entire file
start_line = 1
end_line = line_count
end
-- Clamp to valid 1-based range (avoid 0 or out-of-bounds)
start_line = math.max(1, math.min(start_line, line_count))
end_line = math.max(1, math.min(end_line, line_count))
if end_line < start_line then
end_line = start_line
end
-- Capture injection range so we know exactly where to apply the generated code later
local injection_range = { start_line = start_line, end_line = end_line }
local range_line_count = end_line - start_line + 1
logger.info("commands", string.format("Injection range: lines %d-%d (%d lines) changes will replace this range", start_line, end_line, range_line_count))
-- Open centered prompt window (pattern from 99: acwrite + BufWriteCmd to submit, BufLeave to keep focus)
local prompt_buf = vim.api.nvim_create_buf(false, true)
vim.bo[prompt_buf].buftype = "acwrite"
vim.bo[prompt_buf].bufhidden = "wipe"
vim.bo[prompt_buf].filetype = "markdown"
vim.bo[prompt_buf].swapfile = false
vim.api.nvim_buf_set_name(prompt_buf, "codetyper-prompt")
local win_opts = create_centered_window()
local prompt_win = vim.api.nvim_open_win(prompt_buf, true, {
relative = "editor",
row = win_opts.row,
col = win_opts.col,
width = win_opts.width,
height = win_opts.height,
style = "minimal",
border = win_opts.border,
title = has_selection and " Enter prompt for selection " or " Enter prompt ",
title_pos = "center",
})
vim.wo[prompt_win].wrap = true
vim.api.nvim_set_current_win(prompt_win)
local function close_prompt()
if prompt_win and vim.api.nvim_win_is_valid(prompt_win) then
vim.api.nvim_win_close(prompt_win, true)
end
if prompt_buf and vim.api.nvim_buf_is_valid(prompt_buf) then
vim.api.nvim_buf_delete(prompt_buf, { force = true })
end
prompt_win = nil
prompt_buf = nil
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+$", "")
close_prompt()
if input == "" then
logger.info("commands", "User cancelled prompt input")
return
end
logger.info("commands", "Processing with prompt: " .. input:sub(1, 50) .. (#input > 50 and "..." or ""))
local content = has_selection and (input .. "\n\nCode:\n" .. selection_text) or input
-- 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_col = 1,
end_col = 1,
selection = selection,
user_prompt = input,
-- Explicit injection range (same as start_line/end_line) for downstream
injection_range = injection_range,
}
local autocmds = require("codetyper.adapters.nvim.autocmds")
autocmds.process_single_prompt(bufnr, prompt, filepath, true)
logger.func_exit("commands", "cmd_transform_selection", "completed")
end
local augroup = vim.api.nvim_create_augroup("CodetyperPrompt_" .. prompt_buf, { clear = true })
local submitted = false
-- Submit on :w (acwrite buffer triggers BufWriteCmd)
vim.api.nvim_create_autocmd("BufWriteCmd", {
group = augroup,
buffer = prompt_buf,
callback = function()
if prompt_win and vim.api.nvim_win_is_valid(prompt_win) then
submitted = true
submit_prompt()
end
end,
})
-- Keep focus in prompt window (prevent leaving to other buffers)
vim.api.nvim_create_autocmd("BufLeave", {
group = augroup,
buffer = prompt_buf,
callback = function()
if prompt_win and vim.api.nvim_win_is_valid(prompt_win) then
vim.api.nvim_set_current_win(prompt_win)
end
end,
})
-- Clean up when window is closed (e.g. :q or close button)
vim.api.nvim_create_autocmd("WinClosed", {
group = augroup,
pattern = tostring(prompt_win),
callback = function()
if not submitted then
logger.info("commands", "User cancelled prompt input")
end
close_prompt()
end,
})
local map_opts = { buffer = prompt_buf, noremap = true, silent = true }
-- Normal mode: Enter, :w, or Ctrl+Enter to submit
vim.keymap.set("n", "<CR>", submit_prompt, map_opts)
vim.keymap.set("n", "<C-CR>", submit_prompt, map_opts)
vim.keymap.set("n", "<C-Enter>", submit_prompt, map_opts)
vim.keymap.set("n", "<leader>w", "<cmd>w<cr>", vim.tbl_extend("force", map_opts, { desc = "Submit prompt" }))
-- Insert mode: Ctrl+Enter to submit
vim.keymap.set("i", "<C-CR>", submit_prompt, map_opts)
vim.keymap.set("i", "<C-Enter>", submit_prompt, map_opts)
-- Close/cancel: Esc (in normal), q, or :q
vim.keymap.set("n", "<Esc>", close_prompt, map_opts)
vim.keymap.set("n", "q", close_prompt, map_opts)
vim.cmd("startinsert")
end
--- Index the entire project
local function cmd_index_project()
local indexer = require("codetyper.features.indexer")
@@ -627,59 +171,6 @@ local function cmd_forget(pattern)
end
end
--- Transform a single prompt at cursor position
local function cmd_transform_at_cursor()
local parser = require("codetyper.parser")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
vim.notify(
"[codetyper] cmd_transform_at_cursor called: bufnr=" .. bufnr .. ", filepath=" .. tostring(filepath),
vim.log.levels.DEBUG
)
if filepath == "" then
vim.notify("[codetyper] No file in current buffer", vim.log.levels.DEBUG)
utils.notify("No file in current buffer", vim.log.levels.WARN)
return
end
-- Find prompt at cursor
vim.notify("[codetyper] Calling parser.get_prompt_at_cursor...", vim.log.levels.DEBUG)
local prompt = parser.get_prompt_at_cursor(bufnr)
vim.notify(
"[codetyper] parser.get_prompt_at_cursor returned: " .. (prompt and "prompt found" or "nil"),
vim.log.levels.DEBUG
)
if not prompt then
vim.notify("[codetyper] No prompt found at cursor", vim.log.levels.DEBUG)
utils.notify("No /@ @/ tag at cursor position", vim.log.levels.WARN)
return
end
vim.notify(
"[codetyper] Prompt found: start_line="
.. prompt.start_line
.. ", end_line="
.. prompt.end_line
.. ", content_length="
.. #prompt.content,
vim.log.levels.DEBUG
)
local clean_prompt = parser.clean_prompt(prompt.content)
vim.notify("[codetyper] Clean prompt: " .. clean_prompt:sub(1, 50) .. "...", vim.log.levels.DEBUG)
utils.notify("Transforming: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO)
-- Use the same processing logic as automatic mode (skip processed check for manual mode)
vim.notify("[codetyper] Calling autocmds.process_single_prompt...", vim.log.levels.DEBUG)
autocmds.process_single_prompt(bufnr, prompt, filepath, true)
vim.notify("[codetyper] autocmds.process_single_prompt completed", vim.log.levels.DEBUG)
end
--- Main command dispatcher
---@param args table Command arguments
--- Show LLM accuracy statistics
@@ -732,48 +223,17 @@ local function coder_cmd(args)
local subcommand = args.fargs[1] or "toggle"
local commands = {
open = cmd_open,
close = cmd_close,
toggle = cmd_toggle,
process = cmd_process,
status = cmd_status,
focus = cmd_focus,
tree = cmd_tree,
["tree-view"] = cmd_tree_view,
reset = cmd_reset,
gitignore = cmd_gitignore,
["transform-selection"] = cmd_transform_selection,
["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,
["auto-toggle"] = function()
local preferences = require("codetyper.config.preferences")
preferences.toggle_auto_process()
end,
["auto-set"] = function(args)
local preferences = require("codetyper.config.preferences")
local arg = (args[1] or ""):lower()
if arg == "auto" or arg == "automatic" or arg == "on" then
preferences.set_auto_process(true)
utils.notify("Set to automatic mode", vim.log.levels.INFO)
elseif arg == "manual" or arg == "off" then
preferences.set_auto_process(false)
utils.notify("Set to manual mode", vim.log.levels.INFO)
else
local auto = preferences.is_auto_process_enabled()
if auto == nil then
utils.notify("Mode not set yet (will ask on first prompt)", vim.log.levels.INFO)
else
local mode = auto and "automatic" or "manual"
utils.notify("Currently in " .. mode .. " mode", vim.log.levels.INFO)
end
end
end,
-- LLM smart selection commands
["llm-stats"] = cmd_llm_stats,
["llm-feedback-good"] = function()
@@ -849,25 +309,17 @@ function M.setup()
nargs = "?",
complete = function()
return {
"open",
"close",
"toggle",
"process",
"status",
"focus",
"tree",
"tree-view",
"reset",
"gitignore",
"transform",
"transform-cursor",
"transform-selection",
"index-project",
"index-status",
"memories",
"forget",
"auto-toggle",
"auto-set",
"llm-stats",
"llm-feedback-good",
"llm-feedback-bad",
@@ -884,23 +336,6 @@ function M.setup()
desc = "Codetyper.nvim commands",
})
-- Convenience aliases
vim.api.nvim_create_user_command("CoderOpen", function()
cmd_open()
end, { desc = "Open Coder view" })
vim.api.nvim_create_user_command("CoderClose", function()
cmd_close()
end, { desc = "Close Coder view" })
vim.api.nvim_create_user_command("CoderToggle", function()
cmd_toggle()
end, { desc = "Toggle Coder view" })
vim.api.nvim_create_user_command("CoderProcess", function()
cmd_process()
end, { desc = "Process prompt and generate code" })
vim.api.nvim_create_user_command("CoderTree", function()
cmd_tree()
end, { desc = "Refresh tree.log" })
@@ -909,14 +344,8 @@ function M.setup()
cmd_tree_view()
end, { desc = "View tree.log" })
vim.api.nvim_create_user_command("CoderTransformVisual", function(opts)
local start_line = opts.line1
local end_line = opts.line2
cmd_transform_range(start_line, end_line)
end, { range = true, desc = "Transform /@ @/ tags in visual selection" })
vim.api.nvim_create_user_command("CoderTransformSelection", function()
cmd_transform_selection()
transform.cmd_transform_selection()
end, { desc = "Transform visual selection with custom prompt input" })
-- Project indexer commands
@@ -939,39 +368,6 @@ function M.setup()
nargs = "?",
})
-- Preferences commands
vim.api.nvim_create_user_command("CoderAutoToggle", function()
local preferences = require("codetyper.config.preferences")
preferences.toggle_auto_process()
end, { desc = "Toggle automatic/manual prompt processing" })
vim.api.nvim_create_user_command("CoderAutoSet", function(opts)
local preferences = require("codetyper.config.preferences")
local arg = opts.args:lower()
if arg == "auto" or arg == "automatic" or arg == "on" then
preferences.set_auto_process(true)
vim.notify("Codetyper: Set to automatic mode", vim.log.levels.INFO)
elseif arg == "manual" or arg == "off" then
preferences.set_auto_process(false)
vim.notify("Codetyper: Set to manual mode", vim.log.levels.INFO)
else
-- Show current mode
local auto = preferences.is_auto_process_enabled()
if auto == nil then
vim.notify("Codetyper: Mode not set yet (will ask on first prompt)", vim.log.levels.INFO)
else
local mode = auto and "automatic" or "manual"
vim.notify("Codetyper: Currently in " .. mode .. " mode", vim.log.levels.INFO)
end
end
end, {
desc = "Set prompt processing mode (auto/manual)",
nargs = "?",
complete = function()
return { "auto", "manual" }
end,
})
-- Brain feedback command - teach the brain from your experience
vim.api.nvim_create_user_command("CoderFeedback", function(opts)
local brain = require("codetyper.core.memory")
@@ -1154,17 +550,17 @@ end
function M.setup_keymaps()
-- Visual mode: transform selection with custom prompt input
vim.keymap.set("v", "<leader>ctt", function()
cmd_transform_selection()
transform.cmd_transform_selection()
end, {
silent = true,
desc = "Coder: Transform selection with prompt",
})
-- Normal mode: prompt only (no selection); request is entered in the prompt
vim.keymap.set("n", "<leader>ctt", function()
cmd_transform_selection()
transform.cmd_transform_selection()
end, {
silent = true,
desc = "Coder: Transform with prompt (no selection)",
desc = "Coder: Open prompt window",
})
end

View File

@@ -4,79 +4,57 @@ local M = {}
---@type CoderConfig
local defaults = {
llm = {
provider = "ollama", -- Options: "ollama", "openai", "gemini", "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
},
},
window = {
width = 25, -- 25% of screen width (1/4)
position = "left",
border = "rounded",
},
patterns = {
open_tag = "/@",
close_tag = "@/",
file_pattern = "*.coder.*",
},
auto_gitignore = true,
auto_index = false, -- Auto-create coder companion files on file open
indexer = {
enabled = true, -- Enable project indexing
auto_index = true, -- Index files on save
index_on_open = false, -- Index project when opening
max_file_size = 100000, -- Skip files larger than 100KB
excluded_dirs = { "node_modules", "dist", "build", ".git", ".coder", "__pycache__", "vendor", "target" },
index_extensions = { "lua", "ts", "tsx", "js", "jsx", "py", "go", "rs", "rb", "java", "c", "cpp", "h", "hpp" },
memory = {
enabled = true, -- Enable memory persistence
max_memories = 1000, -- Maximum stored memories
prune_threshold = 0.1, -- Remove low-weight memories
},
},
brain = {
enabled = true, -- Enable brain learning system
auto_learn = true, -- Auto-learn from events
auto_commit = true, -- Auto-commit after threshold
commit_threshold = 10, -- Changes before auto-commit
max_nodes = 5000, -- Maximum nodes before pruning
max_deltas = 500, -- Maximum delta history
prune = {
enabled = true, -- Enable auto-pruning
threshold = 0.1, -- Remove nodes below this weight
unused_days = 90, -- Remove unused nodes after N days
},
output = {
max_tokens = 4000, -- Token budget for LLM context
format = "compact", -- "compact"|"json"|"natural"
},
},
suggestion = {
enabled = true, -- Enable ghost text suggestions (Copilot-style)
auto_trigger = true, -- Auto-trigger on typing
debounce = 150, -- Debounce in milliseconds
use_copilot = true, -- Use copilot.lua suggestions when available, fallback to codetyper
keymap = {
accept = "<Tab>", -- Accept suggestion
next = "<M-]>", -- Next suggestion (Alt+])
prev = "<M-[>", -- Previous suggestion (Alt+[)
dismiss = "<C-]>", -- Dismiss suggestion (Ctrl+])
},
},
llm = {
provider = "ollama", -- Options: "ollama", "openai", "gemini", "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
},
},
auto_gitignore = true,
auto_index = false, -- Auto-create coder companion files on file open
indexer = {
enabled = true, -- Enable project indexing
auto_index = true, -- Index files on save
index_on_open = false, -- Index project when opening
max_file_size = 100000, -- Skip files larger than 100KB
excluded_dirs = { "node_modules", "dist", "build", ".git", ".coder", "__pycache__", "vendor", "target" },
index_extensions = { "lua", "ts", "tsx", "js", "jsx", "py", "go", "rs", "rb", "java", "c", "cpp", "h", "hpp" },
memory = {
enabled = true, -- Enable memory persistence
max_memories = 1000, -- Maximum stored memories
prune_threshold = 0.1, -- Remove low-weight memories
},
},
brain = {
enabled = true, -- Enable brain learning system
auto_learn = true, -- Auto-learn from events
auto_commit = true, -- Auto-commit after threshold
commit_threshold = 10, -- Changes before auto-commit
max_nodes = 5000, -- Maximum nodes before pruning
max_deltas = 500, -- Maximum delta history
prune = {
enabled = true, -- Enable auto-pruning
threshold = 0.1, -- Remove nodes below this weight
unused_days = 90, -- Remove unused nodes after N days
},
output = {
max_tokens = 4000, -- Token budget for LLM context
format = "compact", -- "compact"|"json"|"natural"
},
},
}
--- Deep merge two tables
@@ -84,68 +62,68 @@ local defaults = {
---@param t2 table Table to merge into base
---@return table Merged table
local function deep_merge(t1, t2)
local result = vim.deepcopy(t1)
for k, v in pairs(t2) do
if type(v) == "table" and type(result[k]) == "table" then
result[k] = deep_merge(result[k], v)
else
result[k] = v
end
end
return result
local result = vim.deepcopy(t1)
for k, v in pairs(t2) do
if type(v) == "table" and type(result[k]) == "table" then
result[k] = deep_merge(result[k], v)
else
result[k] = v
end
end
return result
end
--- Setup configuration with user options
---@param opts? CoderConfig User configuration options
---@return CoderConfig Final configuration
function M.setup(opts)
opts = opts or {}
return deep_merge(defaults, opts)
opts = opts or {}
return deep_merge(defaults, opts)
end
--- Get default configuration
---@return CoderConfig Default configuration
function M.get_defaults()
return vim.deepcopy(defaults)
return vim.deepcopy(defaults)
end
--- Validate configuration
---@param config CoderConfig Configuration to validate
---@return boolean, string? Valid status and optional error message
function M.validate(config)
if not config.llm then
return false, "Missing LLM configuration"
end
if not config.llm then
return false, "Missing LLM configuration"
end
local valid_providers = { "ollama", "openai", "gemini", "copilot" }
local is_valid_provider = false
for _, p in ipairs(valid_providers) do
if config.llm.provider == p then
is_valid_provider = true
break
end
end
local valid_providers = { "ollama", "openai", "gemini", "copilot" }
local is_valid_provider = false
for _, p in ipairs(valid_providers) do
if config.llm.provider == p then
is_valid_provider = true
break
end
end
if not is_valid_provider then
return false, "Invalid LLM provider. Must be one of: " .. table.concat(valid_providers, ", ")
end
if not is_valid_provider then
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
-- 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
return true
end
return M

View File

@@ -233,8 +233,24 @@ function M.create_from_event(event, generated_code, confidence, strategy)
local injection_strategy = strategy
local injection_range = nil
-- Handle intent_override from transform-selection (e.g., cursor insert mode)
if event.intent_override and event.intent_override.action then
injection_strategy = event.intent_override.action
-- Use injection_range from transform-selection, not event.range
injection_range = event.injection_range or (event.range and {
start_line = event.range.start_line,
end_line = event.range.end_line,
})
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Using override strategy: %s (%s)", injection_strategy,
injection_range and (injection_range.start_line .. "-" .. injection_range.end_line) or "nil"),
})
end)
-- If we have SEARCH/REPLACE blocks, use that strategy
if use_search_replace then
elseif use_search_replace then
injection_strategy = "search_replace"
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
@@ -519,14 +535,18 @@ local function remove_prompt_tags(bufnr)
return removed
end
--- Check if it's safe to modify the buffer (not in insert mode)
--- Check if it's safe to modify the buffer (not in insert or visual mode)
---@return boolean
local function is_safe_to_modify()
local mode = vim.fn.mode()
-- Don't modify if in insert mode or completion is visible
-- Don't modify if in insert mode, visual mode, or completion is visible
if mode == "i" or mode == "ic" or mode == "ix" then
return false
end
-- Visual modes: v (char), V (line), \22 (block)
if mode == "v" or mode == "V" or mode == "\22" then
return false
end
if vim.fn.pumvisible() == 1 then
return false
end
@@ -540,9 +560,9 @@ end
function M.apply(patch)
logger.info("patch", string.format("apply() entered: id=%s strategy=%s has_range=%s", patch.id, tostring(patch.injection_strategy), patch.injection_range and "yes" or "no"))
-- Check if safe to modify (not in insert mode)
-- Check if safe to modify (not in insert or visual mode)
if not is_safe_to_modify() then
logger.info("patch", "apply aborted: user_typing (insert mode or pum visible)")
logger.info("patch", "apply aborted: not safe (insert/visual mode or pum visible)")
return false, "user_typing"
end

View File

@@ -59,6 +59,13 @@ function M.is_insert_mode()
return mode == "i" or mode == "ic" or mode == "ix"
end
--- Check if we're in visual mode
---@return boolean
function M.is_visual_mode()
local mode = vim.fn.mode()
return mode == "v" or mode == "V" or mode == "\22"
end
--- Check if it's safe to inject code
---@return boolean
---@return string|nil reason if not safe
@@ -71,6 +78,10 @@ function M.is_safe_to_inject()
return false, "insert_mode"
end
if M.is_visual_mode() then
return false, "visual_mode"
end
return true, nil
end
@@ -503,6 +514,13 @@ function M.schedule_patch_flush()
if not waiting_to_flush then
waiting_to_flush = true
logger.info("scheduler", "Waiting for user to finish typing before applying code...")
-- Notify user about the wait
local utils = require("codetyper.support.utils")
if reason == "visual_mode" then
utils.notify("Queue waiting: exit Visual mode to inject code", vim.log.levels.INFO)
elseif reason == "insert_mode" then
utils.notify("Queue waiting: exit Insert mode to inject code", vim.log.levels.INFO)
end
end
-- Retry after a delay - keep waiting for user to finish typing
M.schedule_patch_flush()
@@ -549,6 +567,20 @@ local function setup_autocmds()
desc = "Flush pending patches on InsertLeave",
})
-- Flush patches when leaving visual mode
vim.api.nvim_create_autocmd("ModeChanged", {
group = augroup,
pattern = "[vV\x16]*:*", -- visual mode to any other mode
callback = function()
vim.defer_fn(function()
if not M.is_insert_mode() and not M.is_completion_visible() then
patch.flush_pending_smart()
end
end, state.config.completion_delay_ms)
end,
desc = "Flush pending patches on VisualLeave",
})
-- Flush patches on cursor hold
vim.api.nvim_create_autocmd("CursorHold", {
group = augroup,

View File

@@ -0,0 +1,224 @@
local M = {}
--- Return editor dimensions (from UI, like 99 plugin)
---@return number width
---@return number height
local function get_ui_dimensions()
local ui = vim.api.nvim_list_uis()[1]
if ui then
return ui.width, ui.height
end
return vim.o.columns, vim.o.lines
end
--- Centered floating window config for prompt (2/3 width, 1/3 height)
---@return table { width, height, row, col, border }
local function create_centered_window()
local width, height = get_ui_dimensions()
local win_width = math.floor(width * 2 / 3)
local win_height = math.floor(height / 3)
return {
width = win_width,
height = win_height,
row = math.floor((height - win_height) / 2),
col = math.floor((width - win_width) / 2),
border = "rounded",
}
end
--- Get visual selection text and range
---@return table|nil { text: string, start_line: number, end_line: number }
local function get_visual_selection()
local mode = vim.api.nvim_get_mode().mode
-- Check if in visual mode
local is_visual = mode == "v" or mode == "V" or mode == "\22"
if not is_visual then
return nil
end
-- Get selection range BEFORE any mode changes
local start_line = vim.fn.line("'<")
local end_line = vim.fn.line("'>")
-- Check if marks are valid (might be 0 if not in visual mode)
if start_line <= 0 or end_line <= 0 then
return nil
end
-- Third argument must be a Vim dictionary; empty Lua table can be treated as list
local opts = { type = mode }
local selection = vim.fn.getregion(vim.fn.getpos("'<"), vim.fn.getpos("'>"), opts)
local text = type(selection) == "table" and table.concat(selection, "\n") or tostring(selection or "")
return {
text = text,
start_line = start_line,
end_line = end_line,
}
end
--- Transform visual selection with custom prompt input
--- Opens input window for prompt, processes selection on confirm.
--- When nothing is selected (e.g. from Normal mode), only the prompt is requested.
function M.cmd_transform_selection()
local logger = require("codetyper.support.logger")
logger.func_entry("commands", "cmd_transform_selection", {})
-- Get visual selection (returns table with text, start_line, end_line or nil)
local selection_data = get_visual_selection()
local selection_text = selection_data and selection_data.text or ""
local has_selection = selection_text and #selection_text >= 4
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
local line_count = vim.api.nvim_buf_line_count(bufnr)
line_count = math.max(1, line_count)
-- Range for injection: selection, cursor line when no selection
local start_line, end_line
local is_cursor_insert = false
if has_selection and selection_data then
start_line = selection_data.start_line
end_line = selection_data.end_line
logger.info("commands", string.format("Visual selection: start=%d end=%d selected_text_lines=%d",
start_line, end_line, #vim.split(selection_text, "\n", { plain = true })))
else
-- No selection: insert at current cursor line (not replace whole file)
start_line = vim.fn.line(".")
end_line = start_line
is_cursor_insert = true
end
-- Clamp to valid 1-based range (avoid 0 or out-of-bounds)
start_line = math.max(1, math.min(start_line, line_count))
end_line = math.max(1, math.min(end_line, line_count))
if end_line < start_line then
end_line = start_line
end
-- Capture injection range so we know exactly where to apply the generated code later
local injection_range = { start_line = start_line, end_line = end_line }
local range_line_count = end_line - start_line + 1
-- Open centered prompt window (pattern from 99: acwrite + BufWriteCmd to submit, BufLeave to keep focus)
local prompt_buf = vim.api.nvim_create_buf(false, true)
vim.bo[prompt_buf].buftype = "acwrite"
vim.bo[prompt_buf].bufhidden = "wipe"
vim.bo[prompt_buf].filetype = "markdown"
vim.bo[prompt_buf].swapfile = false
vim.api.nvim_buf_set_name(prompt_buf, "codetyper-prompt")
local win_opts = create_centered_window()
local prompt_win = vim.api.nvim_open_win(prompt_buf, true, {
relative = "editor",
row = win_opts.row,
col = win_opts.col,
width = win_opts.width,
height = win_opts.height,
style = "minimal",
border = win_opts.border,
title = has_selection and " Enter prompt for selection " or " Enter prompt ",
title_pos = "center",
})
vim.wo[prompt_win].wrap = true
vim.api.nvim_set_current_win(prompt_win)
local function close_prompt()
if prompt_win and vim.api.nvim_win_is_valid(prompt_win) then
vim.api.nvim_win_close(prompt_win, true)
end
if prompt_buf and vim.api.nvim_buf_is_valid(prompt_buf) then
vim.api.nvim_buf_delete(prompt_buf, { force = true })
end
prompt_win = nil
prompt_buf = nil
end
local submitted = false
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+$", "")
close_prompt()
if input == "" then
logger.info("commands", "User cancelled prompt input")
return
end
local content
if has_selection then
content = input .. "\n\nCode to replace (replace this code):\n" .. selection_text
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_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),
}
local autocmds = require("codetyper.adapters.nvim.autocmds")
autocmds.process_single_prompt(bufnr, prompt, filepath, true)
end
local augroup = vim.api.nvim_create_augroup("CodetyperPrompt_" .. prompt_buf, { clear = true })
-- Submit on :w (acwrite buffer triggers BufWriteCmd)
vim.api.nvim_create_autocmd("BufWriteCmd", {
group = augroup,
buffer = prompt_buf,
callback = function()
if prompt_win and vim.api.nvim_win_is_valid(prompt_win) then
submitted = true
submit_prompt()
end
end,
})
-- Keep focus in prompt window (prevent leaving to other buffers)
vim.api.nvim_create_autocmd("BufLeave", {
group = augroup,
buffer = prompt_buf,
callback = function()
if prompt_win and vim.api.nvim_win_is_valid(prompt_win) then
vim.api.nvim_set_current_win(prompt_win)
end
end,
})
-- Clean up when window is closed (e.g. :q or close button)
vim.api.nvim_create_autocmd("WinClosed", {
group = augroup,
pattern = tostring(prompt_win),
callback = function()
if not submitted then
logger.info("commands", "User cancelled prompt input")
end
close_prompt()
end,
})
local map_opts = { buffer = prompt_buf, noremap = true, silent = true }
-- Normal mode: Enter, :w, or Ctrl+Enter to submit
vim.keymap.set("n", "<CR>", submit_prompt, map_opts)
vim.keymap.set("n", "<C-CR>", submit_prompt, map_opts)
vim.keymap.set("n", "<C-Enter>", submit_prompt, map_opts)
vim.keymap.set("n", "<leader>w", "<cmd>w<cr>", vim.tbl_extend("force", map_opts, { desc = "Submit prompt" }))
-- Insert mode: Ctrl+Enter to submit
vim.keymap.set("i", "<C-CR>", submit_prompt, map_opts)
vim.keymap.set("i", "<C-Enter>", submit_prompt, map_opts)
-- Close/cancel: Esc (in normal), q, or :q
vim.keymap.set("n", "<Esc>", close_prompt, map_opts)
vim.keymap.set("n", "q", close_prompt, map_opts)
vim.cmd("startinsert")
end
return M

View File

@@ -18,66 +18,66 @@ M._initialized = false
--- Setup the plugin with user configuration
---@param opts? CoderConfig User configuration options
function M.setup(opts)
if M._initialized then
return
end
if M._initialized then
return
end
local config = require("codetyper.config.defaults")
M.config = config.setup(opts)
local config = require("codetyper.config.defaults")
M.config = config.setup(opts)
-- Initialize modules
local commands = require("codetyper.adapters.nvim.commands")
local gitignore = require("codetyper.support.gitignore")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local tree = require("codetyper.support.tree")
local completion = require("codetyper.features.completion.inline")
-- Initialize modules
local commands = require("codetyper.adapters.nvim.commands")
local gitignore = require("codetyper.support.gitignore")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local tree = require("codetyper.support.tree")
local completion = require("codetyper.features.completion.inline")
-- Register commands
commands.setup()
-- Register commands
commands.setup()
-- Setup autocommands
autocmds.setup()
-- Setup autocommands
autocmds.setup()
-- Setup file reference completion
completion.setup()
-- Setup file reference completion
completion.setup()
-- Ensure .gitignore has coder files excluded
gitignore.ensure_ignored()
-- Ensure .gitignore has coder files excluded
gitignore.ensure_ignored()
-- Initialize tree logging (creates .coder folder and initial tree.log)
tree.setup()
-- Initialize tree logging (creates .coder folder and initial tree.log)
tree.setup()
-- Initialize project indexer if enabled
if M.config.indexer and M.config.indexer.enabled then
local indexer = require("codetyper.features.indexer")
indexer.setup(M.config.indexer)
end
-- Initialize project indexer if enabled
if M.config.indexer and M.config.indexer.enabled then
local indexer = require("codetyper.features.indexer")
indexer.setup(M.config.indexer)
end
-- Initialize brain learning system if enabled
if M.config.brain and M.config.brain.enabled then
local brain = require("codetyper.core.memory")
brain.setup(M.config.brain)
end
-- Initialize brain learning system if enabled
if M.config.brain and M.config.brain.enabled then
local brain = require("codetyper.core.memory")
brain.setup(M.config.brain)
end
-- Setup inline ghost text suggestions (Copilot-style)
if M.config.suggestion and M.config.suggestion.enabled then
local suggestion = require("codetyper.features.completion.suggestion")
suggestion.setup(M.config.suggestion)
end
-- Setup inline ghost text suggestions (Copilot-style)
if M.config.suggestion and M.config.suggestion.enabled then
local suggestion = require("codetyper.features.completion.suggestion")
suggestion.setup(M.config.suggestion)
end
M._initialized = true
M._initialized = true
end
--- Get current configuration
---@return CoderConfig
function M.get_config()
return M.config
return M.config
end
--- Check if plugin is initialized
---@return boolean
function M.is_initialized()
return M._initialized
return M._initialized
end
return M

View File

@@ -12,18 +12,6 @@ describe("config", function()
assert.equals("claude", defaults.llm.provider)
end)
it("should have window configuration", function()
assert.is_table(defaults.window)
assert.equals(25, defaults.window.width)
assert.equals("left", defaults.window.position)
end)
it("should have pattern configuration", function()
assert.is_table(defaults.patterns)
assert.equals("/@", defaults.patterns.open_tag)
assert.equals("@/", defaults.patterns.close_tag)
end)
it("should have scheduler configuration", function()
assert.is_table(defaults.scheduler)
assert.is_boolean(defaults.scheduler.enabled)