Patch/inject: plain-code inline, inject() API, range capture, logger

- Inline prompts: use plain 'replace selection' prompt instead of SEARCH/REPLACE
- Add codetyper.inject.inject(bufnr, code, opts) for patch apply (replace/insert/append)
- Patch: on SEARCH/REPLACE failure use REPLACE parts only; inline always replace range
- Fix 0-0 range: clamp in create_from_event, prefer scope_range when invalid
- Commands: capture injection range from selection (derive end from line count); no selection = whole file
- Commands: log injection range; pass injection_range in prompt; autocmds prefer it
- Replace diagnostic logs with codetyper.support.logger in patch and scheduler
- Fix patch apply syntax (code_to_inject variable for multi-line logger call)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-16 18:06:21 -05:00
parent e534d607d8
commit 4463a8144d
16 changed files with 1668 additions and 283 deletions

View File

@@ -263,11 +263,6 @@ The plugin detects the type of request from your prompt:
:CoderTransformVisual
Transform selected /@ @/ tags (visual mode).
*:CoderIndex*
:CoderIndex
Open coder companion file for current source file.
*:CoderLogs*
:CoderLogs
Toggle the logs panel showing LLM request details.

41
doc/tags Normal file
View File

@@ -0,0 +1,41 @@
:Coder codetyper.txt /*:Coder*
:CoderAgent codetyper.txt /*:CoderAgent*
:CoderAgentStop codetyper.txt /*:CoderAgentStop*
:CoderAgentToggle codetyper.txt /*:CoderAgentToggle*
:CoderAsk codetyper.txt /*:CoderAsk*
:CoderAskClear codetyper.txt /*:CoderAskClear*
:CoderAskToggle codetyper.txt /*:CoderAskToggle*
:CoderClose codetyper.txt /*:CoderClose*
:CoderOpen codetyper.txt /*:CoderOpen*
:CoderProcess codetyper.txt /*:CoderProcess*
:CoderToggle codetyper.txt /*:CoderToggle*
:CoderTransform codetyper.txt /*:CoderTransform*
:CoderTransform codetyper.txt /*:CoderTransform*
:CoderTransformCursor codetyper.txt /*:CoderTransformCursor*
:CoderTransformCursor codetyper.txt /*:CoderTransformCursor*
:CoderTransformVisual codetyper.txt /*:CoderTransformVisual*
:CoderTransformVisual codetyper.txt /*:CoderTransformVisual*
:CoderTree codetyper.txt /*:CoderTree*
:CoderTreeView codetyper.txt /*:CoderTreeView*
:CoderType codetyper.txt /*:CoderType*
codetyper-agent codetyper.txt /*codetyper-agent*
codetyper-api codetyper.txt /*codetyper-api*
codetyper-claude codetyper.txt /*codetyper-claude*
codetyper-commands codetyper.txt /*codetyper-commands*
codetyper-configuration codetyper.txt /*codetyper-configuration*
codetyper-contents codetyper.txt /*codetyper-contents*
codetyper-copilot codetyper.txt /*codetyper-copilot*
codetyper-gemini codetyper.txt /*codetyper-gemini*
codetyper-installation codetyper.txt /*codetyper-installation*
codetyper-introduction codetyper.txt /*codetyper-introduction*
codetyper-keymaps codetyper.txt /*codetyper-keymaps*
codetyper-ollama codetyper.txt /*codetyper-ollama*
codetyper-openai codetyper.txt /*codetyper-openai*
codetyper-providers codetyper.txt /*codetyper-providers*
codetyper-requirements codetyper.txt /*codetyper-requirements*
codetyper-transform codetyper.txt /*codetyper-transform*
codetyper-usage codetyper.txt /*codetyper-usage*
codetyper.get_config() codetyper.txt /*codetyper.get_config()*
codetyper.is_initialized() codetyper.txt /*codetyper.is_initialized()*
codetyper.setup() codetyper.txt /*codetyper.setup()*
codetyper.txt codetyper.txt /*codetyper.txt*

View File

@@ -280,6 +280,10 @@ function M.setup()
end,
desc = "Auto-index source files with coder companion",
})
-- Thinking indicator (throbber) cleanup on exit
local thinking = require("codetyper.adapters.nvim.ui.thinking")
thinking.setup()
end
--- Get config with fallback defaults
@@ -299,6 +303,47 @@ local function get_config_safe()
return config
end
--- Create extmarks for injection range so position survives user edits (99-style).
---@param target_bufnr number Target buffer (where code will be injected)
---@param range { start_line: number, end_line: number } Range to mark (1-based)
---@return table|nil injection_marks { start_mark, end_mark } or nil if buffer invalid
local function create_injection_marks(target_bufnr, range)
if not range or target_bufnr == -1 or not vim.api.nvim_buf_is_valid(target_bufnr) then
return nil
end
local line_count = vim.api.nvim_buf_line_count(target_bufnr)
if line_count == 0 then
return nil
end
-- Clamp to valid 1-based line range (event range may refer to source buffer, target can be different)
local start_line = math.max(1, math.min(range.start_line, line_count))
local end_line = math.max(1, math.min(range.end_line, line_count))
if start_line > end_line then
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_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
)
if not start_mark.id or not end_mark.id then
return nil
end
return { start_mark = start_mark, end_mark = end_mark }
end
--- Read attached files from prompt content
---@param prompt_content string Prompt content
---@param base_path string Base path to resolve relative file paths
@@ -401,10 +446,7 @@ function M.check_for_closed_prompt()
local patch_mod = require("codetyper.core.diff.patch")
local intent_mod = require("codetyper.core.intent")
local scope_mod = require("codetyper.core.scope")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
-- Open logs panel to show progress
logs_panel.ensure_open()
-- In-buffer placeholder "@thinking .... end thinking" is inserted when worker starts (scheduler)
-- Take buffer snapshot
local snapshot = patch_mod.snapshot_buffer(bufnr, {
@@ -497,11 +539,27 @@ function M.check_for_closed_prompt()
priority = 3 -- Lower priority for tests and docs
end
-- Enqueue the event
-- 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_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)
local rs = math.max(1, math.min(raw_start, tc))
local re = math.max(1, math.min(raw_end, tc))
if re < rs then
re = rs
end
local event_range = { start_line = rs, end_line = re }
-- Extmarks for injection range (99-style: position survives user typing)
local range_for_marks = scope_range or event_range
local injection_marks = create_injection_marks(target_bufnr, range_for_marks)
-- Enqueue the event (event.range = where to apply the generated code)
queue.enqueue({
id = queue.generate_id(),
bufnr = bufnr,
range = { start_line = prompt.start_line, end_line = prompt.end_line },
range = event_range,
timestamp = os.clock(),
changedtick = snapshot.changedtick,
content_hash = snapshot.content_hash,
@@ -515,6 +573,7 @@ function M.check_for_closed_prompt()
scope_text = scope_text,
scope_range = scope_range,
attached_files = attached_files,
injection_marks = injection_marks,
})
local scope_info = scope
@@ -571,10 +630,7 @@ function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_che
local patch_mod = require("codetyper.core.diff.patch")
local intent_mod = require("codetyper.core.intent")
local scope_mod = require("codetyper.core.scope")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
-- Open logs panel to show progress
logs_panel.ensure_open()
-- In-buffer placeholder "@thinking .... end thinking" is inserted when worker starts (scheduler)
-- Take buffer snapshot
local snapshot = patch_mod.snapshot_buffer(bufnr, {
@@ -664,11 +720,28 @@ function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_che
priority = 3
end
-- Enqueue the event
-- Use captured injection range when provided (from transform-selection), 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_end = (prompt.injection_range and prompt.injection_range.end_line) or prompt.end_line or 1
-- Clamp to target buffer (1-based, valid lines)
local tc = vim.api.nvim_buf_line_count(target_bufnr)
tc = math.max(1, tc)
local rs = math.max(1, math.min(raw_start, tc))
local re = math.max(1, math.min(raw_end, tc))
if re < rs then
re = rs
end
local event_range = { start_line = rs, end_line = re }
-- Extmarks for injection range (99-style: position survives user typing)
local range_for_marks = scope_range or event_range
local injection_marks = create_injection_marks(target_bufnr, range_for_marks)
-- Enqueue the event (event.range = where to apply the generated code)
queue.enqueue({
id = queue.generate_id(),
bufnr = bufnr,
range = { start_line = prompt.start_line, end_line = prompt.end_line },
range = event_range,
timestamp = os.clock(),
changedtick = snapshot.changedtick,
content_hash = snapshot.content_hash,
@@ -682,6 +755,7 @@ function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_che
scope_text = scope_text,
scope_range = scope_range,
attached_files = attached_files,
injection_marks = injection_marks,
})
local scope_info = scope
@@ -1072,7 +1146,9 @@ function M.update_brain_from_file(filepath)
name = summary,
description = #functions .. " functions, " .. #classes .. " classes",
language = ext,
symbols = vim.tbl_map(function(f) return f.name end, functions),
symbols = vim.tbl_map(function(f)
return f.name
end, functions),
example = nil,
},
})
@@ -1309,7 +1385,18 @@ function M.auto_index_file(bufnr)
local comment_prefix = "--"
local comment_block_start = "--[["
local comment_block_end = "]]"
if ext == "ts" or ext == "tsx" or ext == "js" or ext == "jsx" or ext == "java" or ext == "c" or ext == "cpp" or ext == "cs" or ext == "go" or ext == "rs" then
if
ext == "ts"
or ext == "tsx"
or ext == "js"
or ext == "jsx"
or ext == "java"
or ext == "c"
or ext == "cpp"
or ext == "cs"
or ext == "go"
or ext == "rs"
then
comment_prefix = "//"
comment_block_start = "/*"
comment_block_end = "*/"
@@ -1337,27 +1424,54 @@ function M.auto_index_file(bufnr)
local pseudo_code = {}
-- Header
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(
pseudo_code,
comment_prefix
.. " ═══════════════════════════════════════════════════════════"
)
table.insert(pseudo_code, comment_prefix .. " CODER COMPANION: " .. filename)
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(pseudo_code, comment_prefix .. " This file describes the business logic and behavior of " .. filename)
table.insert(
pseudo_code,
comment_prefix
.. " ═══════════════════════════════════════════════════════════"
)
table.insert(
pseudo_code,
comment_prefix .. " This file describes the business logic and behavior of " .. filename
)
table.insert(pseudo_code, comment_prefix .. " Edit this pseudo-code to guide code generation.")
table.insert(pseudo_code, comment_prefix .. " Use /@ @/ tags for specific generation requests.")
table.insert(pseudo_code, comment_prefix .. "")
-- Module purpose
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " MODULE PURPOSE:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " TODO: Describe what this module/file is responsible for")
table.insert(pseudo_code, comment_prefix .. " Example: \"Handles user authentication and session management\"")
table.insert(pseudo_code, comment_prefix .. ' Example: "Handles user authentication and session management"')
table.insert(pseudo_code, comment_prefix .. "")
-- Dependencies section
if #imports > 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " DEPENDENCIES:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
for _, imp in ipairs(imports) do
table.insert(pseudo_code, comment_prefix .. "" .. imp)
end
@@ -1366,9 +1480,17 @@ function M.auto_index_file(bufnr)
-- Classes section
if #classes > 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " CLASSES:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
for _, class in ipairs(classes) do
table.insert(pseudo_code, comment_prefix .. "")
table.insert(pseudo_code, comment_prefix .. " class " .. class.name .. ":")
@@ -1381,9 +1503,17 @@ function M.auto_index_file(bufnr)
-- Functions section
if #functions > 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " FUNCTIONS:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
for _, func in ipairs(functions) do
table.insert(pseudo_code, comment_prefix .. "")
table.insert(pseudo_code, comment_prefix .. " " .. func.name .. "():")
@@ -1398,9 +1528,17 @@ function M.auto_index_file(bufnr)
-- If empty file, provide starter template
if #functions == 0 and #classes == 0 then
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " PLANNED STRUCTURE:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " TODO: Describe what you want to build in this file")
table.insert(pseudo_code, comment_prefix .. "")
table.insert(pseudo_code, comment_prefix .. " Example pseudo-code:")
@@ -1414,9 +1552,17 @@ function M.auto_index_file(bufnr)
end
-- Business rules section
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " BUSINESS RULES:")
table.insert(pseudo_code, comment_prefix .. " ─────────────────────────────────────────────────────────────")
table.insert(
pseudo_code,
comment_prefix
.. " ─────────────────────────────────────────────────────────────"
)
table.insert(pseudo_code, comment_prefix .. " TODO: Document any business rules, constraints, or requirements")
table.insert(pseudo_code, comment_prefix .. " Example:")
table.insert(pseudo_code, comment_prefix .. " - Users must be authenticated before accessing this feature")
@@ -1425,9 +1571,17 @@ function M.auto_index_file(bufnr)
table.insert(pseudo_code, comment_prefix .. "")
-- Footer with generation tags example
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(
pseudo_code,
comment_prefix
.. " ═══════════════════════════════════════════════════════════"
)
table.insert(pseudo_code, comment_prefix .. " Use /@ @/ tags below to request code generation:")
table.insert(pseudo_code, comment_prefix .. " ═══════════════════════════════════════════════════════════")
table.insert(
pseudo_code,
comment_prefix
.. " ═══════════════════════════════════════════════════════════"
)
table.insert(pseudo_code, "")
utils.write_file(coder_path, table.concat(pseudo_code, "\n"))
@@ -1442,80 +1596,6 @@ function M.auto_index_file(bufnr)
end
end
--- Open the coder companion for the current file
---@param open_split? boolean Whether to open in split view (default: true)
function M.open_coder_companion(open_split)
open_split = open_split ~= false -- Default to true
local filepath = vim.fn.expand("%:p")
if not filepath or filepath == "" then
utils.notify("No file open", vim.log.levels.WARN)
return
end
if utils.is_coder_file(filepath) then
utils.notify("Already in coder file", vim.log.levels.INFO)
return
end
local coder_path = utils.get_coder_path(filepath)
-- Create if it doesn't exist
if not utils.file_exists(coder_path) then
local filename = vim.fn.fnamemodify(filepath, ":t")
local ext = vim.fn.fnamemodify(filepath, ":e")
local comment_prefix = "--"
if vim.tbl_contains({ "js", "jsx", "ts", "tsx", "java", "c", "cpp", "cs", "go", "rs", "php" }, ext) then
comment_prefix = "//"
elseif vim.tbl_contains({ "py", "sh", "zsh", "yaml", "yml" }, ext) then
comment_prefix = "#"
elseif vim.tbl_contains({ "html", "md" }, ext) then
comment_prefix = "<!--"
end
local close_comment = comment_prefix == "<!--" and " -->" or ""
local template = string.format(
[[%s Coder companion for %s%s
%s Use /@ @/ tags to write pseudo-code prompts%s
%s Example:%s
%s /@%s
%s Add a function that validates user input%s
%s - Check for empty strings%s
%s - Validate email format%s
%s @/%s
]],
comment_prefix,
filename,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment,
comment_prefix,
close_comment
)
utils.write_file(coder_path, template)
end
if open_split then
-- Use the window module to open split view
local window = require("codetyper.adapters.nvim.windows")
window.open_split(coder_path, filepath)
else
-- Just open the coder file
vim.cmd("edit " .. vim.fn.fnameescape(coder_path))
end
end
--- Clear auto-indexed tracking for a buffer
---@param bufnr number Buffer number
function M.clear_auto_indexed(bufnr)

View File

@@ -88,6 +88,32 @@ local function cmd_toggle()
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
@@ -249,40 +275,6 @@ local function cmd_focus()
end
end
--- Transform inline /@ @/ tags in current file
--- Works on ANY file, not just .coder.* files
local function cmd_transform()
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 prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
utils.notify("No /@ @/ tags found in current file", vim.log.levels.INFO)
return
end
utils.notify("Transforming " .. #prompts .. " prompt(s)...", vim.log.levels.INFO)
utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO)
-- Reset processed prompts tracking so we can re-process them (silent mode)
autocmds.reset_processed(bufnr, true)
-- Use the same processing logic as automatic mode
-- This ensures intent detection, scope resolution, and all other logic is identical
autocmds.check_all_prompts()
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)
@@ -326,12 +318,188 @@ local function cmd_transform_range(start_line, end_line)
end
end
--- Command wrapper for visual selection transform
local function cmd_transform_visual()
-- Get visual selection marks
local start_line = vim.fn.line("'<")
local end_line = vim.fn.line("'>")
cmd_transform_range(start_line, end_line)
--- 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
@@ -467,24 +635,49 @@ local function cmd_transform_at_cursor()
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
@@ -549,8 +742,8 @@ local function coder_cmd(args)
["tree-view"] = cmd_tree_view,
reset = cmd_reset,
gitignore = cmd_gitignore,
transform = cmd_transform,
["transform-cursor"] = cmd_transform_at_cursor,
["transform-selection"] = cmd_transform_selection,
["index-project"] = cmd_index_project,
["index-status"] = cmd_index_status,
@@ -668,6 +861,7 @@ function M.setup()
"gitignore",
"transform",
"transform-cursor",
"transform-selection",
"index-project",
"index-status",
"memories",
@@ -715,26 +909,15 @@ function M.setup()
cmd_tree_view()
end, { desc = "View tree.log" })
-- Transform commands (inline /@ @/ tag replacement)
vim.api.nvim_create_user_command("CoderTransform", function()
cmd_transform()
end, { desc = "Transform all /@ @/ tags in current file" })
vim.api.nvim_create_user_command("CoderTransformCursor", function()
cmd_transform_at_cursor()
end, { desc = "Transform /@ @/ tag at cursor" })
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" })
-- Index command - open coder companion for current file
vim.api.nvim_create_user_command("CoderIndex", function()
local autocmds = require("codetyper.adapters.nvim.autocmds")
autocmds.open_coder_companion()
end, { desc = "Open coder companion for current file" })
vim.api.nvim_create_user_command("CoderTransformSelection", function()
cmd_transform_selection()
end, { desc = "Transform visual selection with custom prompt input" })
-- Project indexer commands
vim.api.nvim_create_user_command("CoderIndexProject", function()
@@ -969,28 +1152,19 @@ end
--- Setup default keymaps for transform commands
function M.setup_keymaps()
-- Visual mode: transform selected /@ @/ tags
vim.keymap.set("v", "<leader>ctt", ":<C-u>CoderTransformVisual<CR>", {
-- Visual mode: transform selection with custom prompt input
vim.keymap.set("v", "<leader>ctt", function()
cmd_transform_selection()
end, {
silent = true,
desc = "Coder: Transform selected tags",
desc = "Coder: Transform selection with prompt",
})
-- Normal mode: transform tag at cursor
vim.keymap.set("n", "<leader>ctt", "<cmd>CoderTransformCursor<CR>", {
-- Normal mode: prompt only (no selection); request is entered in the prompt
vim.keymap.set("n", "<leader>ctt", function()
cmd_transform_selection()
end, {
silent = true,
desc = "Coder: Transform tag at cursor",
})
-- Normal mode: transform all tags in file
vim.keymap.set("n", "<leader>ctT", "<cmd>CoderTransform<CR>", {
silent = true,
desc = "Coder: Transform all tags in file",
})
-- Index keymap - open coder companion
vim.keymap.set("n", "<leader>ci", "<cmd>CoderIndex<CR>", {
silent = true,
desc = "Coder: Open coder companion for file",
desc = "Coder: Transform with prompt (no selection)",
})
end

View File

@@ -0,0 +1,171 @@
---@mod codetyper.ui.thinking Thinking indicator (99-style status window + throbber)
---@brief [[
--- Shows a small top-right floating window with animated spinner while prompts are processing.
--- Replaces opening the full logs panel during code generation.
---@brief ]]
local M = {}
local throbber = require("codetyper.adapters.nvim.ui.throbber")
local queue = require("codetyper.core.events.queue")
---@class ThinkingState
---@field win_id number|nil
---@field buf_id number|nil
---@field throbber Throbber|nil
---@field queue_listener_id number|nil
---@field timer number|nil Defer timer for polling
local state = {
win_id = nil,
buf_id = nil,
throbber = nil,
queue_listener_id = nil,
timer = nil,
}
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
--- Top-right status window config (like 99)
local function status_window_config()
local width, _ = get_ui_dimensions()
local win_width = math.min(40, math.floor(width / 3))
return {
relative = "editor",
row = 0,
col = width,
width = win_width,
height = 2,
anchor = "NE",
style = "minimal",
border = nil,
zindex = 100,
}
end
local function active_count()
return queue.pending_count() + queue.processing_count()
end
local function close_window()
if state.timer then
pcall(vim.fn.timer_stop, state.timer)
state.timer = nil
end
if state.throbber then
state.throbber:stop()
state.throbber = nil
end
if state.queue_listener_id then
queue.remove_listener(state.queue_listener_id)
state.queue_listener_id = nil
end
if state.win_id and vim.api.nvim_win_is_valid(state.win_id) then
vim.api.nvim_win_close(state.win_id, true)
end
if state.buf_id and vim.api.nvim_buf_is_valid(state.buf_id) then
vim.api.nvim_buf_delete(state.buf_id, { force = true })
end
state.win_id = nil
state.buf_id = nil
end
local function update_display(icon, force)
if not state.buf_id or not vim.api.nvim_buf_is_valid(state.buf_id) then
return
end
local count = active_count()
if count <= 0 and not force then
return
end
local line = (count <= 1)
and (icon .. " Thinking...")
or (icon .. " Thinking... (" .. 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
vim.api.nvim_buf_set_lines(state.buf_id, 0, -1, false, { line })
vim.bo[state.buf_id].modifiable = false
end
end)
end
local function check_and_hide()
if active_count() > 0 then
return
end
close_window()
end
--- Ensure the thinking status window is shown and throbber is running.
--- Call when starting prompt processing (instead of logs_panel.ensure_open).
function M.ensure_shown()
if state.win_id and vim.api.nvim_win_is_valid(state.win_id) then
-- Already shown; throbber keeps running
return
end
state.buf_id = vim.api.nvim_create_buf(false, true)
vim.bo[state.buf_id].buftype = "nofile"
vim.bo[state.buf_id].bufhidden = "wipe"
vim.bo[state.buf_id].swapfile = false
local config = status_window_config()
state.win_id = vim.api.nvim_open_win(state.buf_id, false, config)
vim.wo[state.win_id].wrap = true
vim.wo[state.win_id].number = false
vim.wo[state.win_id].relativenumber = false
state.throbber = throbber.new(function(icon)
update_display(icon)
-- When active count drops to 0, hide after a short delay
if active_count() <= 0 then
vim.defer_fn(check_and_hide, 300)
end
end)
state.throbber:start()
-- Queue listener: when queue updates, check if we should hide
state.queue_listener_id = queue.add_listener(function(_, _, _)
vim.schedule(function()
if active_count() <= 0 then
vim.defer_fn(check_and_hide, 400)
end
end)
end)
-- Initial line (force show before enqueue so window is not empty)
local icon = (state.throbber and state.throbber.icon_set and state.throbber.icon_set[1]) or ""
update_display(icon, true)
end
--- Force close the thinking window (e.g. on VimLeavePre).
function M.close()
close_window()
end
--- Check if thinking window is currently visible.
---@return boolean
function M.is_shown()
return state.win_id ~= nil and vim.api.nvim_win_is_valid(state.win_id)
end
--- Register autocmds for cleanup on exit.
function M.setup()
local group = vim.api.nvim_create_augroup("CodetyperThinking", { clear = true })
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
M.close()
end,
desc = "Close thinking window before exiting Neovim",
})
end
return M

View File

@@ -0,0 +1,87 @@
---@mod codetyper.ui.throbber Animated thinking spinner (99-style)
---@brief [[
--- Unicode throbber icons, runs a timer and calls cb(icon) every tick.
---@brief ]]
local M = {}
local throb_icons = {
{ "", "", "", "", "", "", "", "", "", "" },
{ "", "", "", "" },
{ "", "", "", "", "", "", "", "" },
{ "", "", "", "" },
{ "", "", "", "", "", "" },
}
local throb_time = 1200
local cooldown_time = 100
local tick_time = 100
local function now()
return vim.uv and vim.uv.now() or (os.clock() * 1000)
end
---@class Throbber
---@field state "init"|"throbbing"|"cooldown"|"stopped"
---@field start_time number
---@field section_time number
---@field opts { throb_time: number, cooldown_time: number }
---@field cb fun(icon: string)
---@field icon_set string[]
---@field _run fun(self: Throbber)
local Throbber = {}
Throbber.__index = Throbber
---@param cb fun(icon: string)
---@param opts? { throb_time?: number, cooldown_time?: number }
---@return Throbber
function M.new(cb, opts)
opts = opts or {}
local throb_time_ms = opts.throb_time or throb_time
local cooldown_ms = opts.cooldown_time or cooldown_time
local icon_set = throb_icons[math.random(#throb_icons)]
return setmetatable({
state = "init",
start_time = 0,
section_time = throb_time_ms,
opts = { throb_time = throb_time_ms, cooldown_time = cooldown_ms },
cb = cb,
icon_set = icon_set,
}, Throbber)
end
function Throbber:_run()
if self.state ~= "throbbing" and self.state ~= "cooldown" then
return
end
local elapsed = now() - self.start_time
local percent = math.min(1, elapsed / self.section_time)
local idx = math.floor(percent * #self.icon_set) + 1
idx = math.min(idx, #self.icon_set)
local icon = self.icon_set[idx]
if percent >= 1 then
self.state = self.state == "cooldown" and "throbbing" or "cooldown"
self.start_time = now()
self.section_time = (self.state == "cooldown") and self.opts.cooldown_time or self.opts.throb_time
end
self.cb(icon)
vim.defer_fn(function()
self:_run()
end, tick_time)
end
function Throbber:start()
self.start_time = now()
self.section_time = self.opts.throb_time
self.state = "throbbing"
self:_run()
end
function Throbber:stop()
self.state = "stopped"
end
return M

View File

@@ -8,6 +8,7 @@
local M = {}
local params = require("codetyper.params.agents.patch")
local logger = require("codetyper.support.logger")
--- Lazy load inject module to avoid circular requires
@@ -242,6 +243,30 @@ function M.create_from_event(event, generated_code, confidence, strategy)
message = string.format("Using SEARCH/REPLACE mode with %d block(s)", #sr_blocks),
})
end)
elseif is_inline and event.range then
-- Inline prompts: always replace the selection (we asked LLM for "code that replaces lines X-Y")
injection_strategy = "replace"
local start_line = math.max(1, event.range.start_line or 1)
local end_line = math.max(1, event.range.end_line or 1)
if end_line < start_line then
end_line = start_line
end
-- Prefer scope_range if event.range is invalid (0-0) and we have scope
if (event.range.start_line == 0 or event.range.end_line == 0) and event.scope_range then
start_line = math.max(1, event.scope_range.start_line or 1)
end_line = math.max(1, event.scope_range.end_line or 1)
if end_line < start_line then
end_line = start_line
end
end
injection_range = { start_line = start_line, end_line = end_line }
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Inline: replace lines %d-%d", start_line, end_line),
})
end)
elseif not injection_strategy and event.intent then
local intent_mod = require("codetyper.core.intent")
if intent_mod.is_replacement(event.intent) then
@@ -294,6 +319,18 @@ function M.create_from_event(event, generated_code, confidence, strategy)
injection_strategy = injection_strategy or "append"
local range_str = injection_range
and string.format("%d-%d", injection_range.start_line, injection_range.end_line)
or "nil"
logger.info("patch", string.format(
"create: is_inline=%s strategy=%s range=%s use_sr=%s intent_action=%s",
tostring(is_inline),
injection_strategy,
range_str,
tostring(use_search_replace),
event.intent and event.intent.action or "nil"
))
return {
id = M.generate_id(),
event_id = event.id,
@@ -316,6 +353,8 @@ function M.create_from_event(event, generated_code, confidence, strategy)
-- SEARCH/REPLACE support
use_search_replace = use_search_replace,
search_replace_blocks = use_search_replace and sr_blocks or nil,
-- Extmarks for injection range (99-style: apply at current position after user edits)
injection_marks = event.injection_marks,
}
end
@@ -499,24 +538,28 @@ end
---@return boolean success
---@return string|nil error
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)
if not is_safe_to_modify() then
logger.info("patch", "apply aborted: user_typing (insert mode or pum visible)")
return false, "user_typing"
end
-- Check staleness first
local is_stale, stale_reason = M.is_stale(patch)
-- Check staleness (skip when we have valid extmarks - 99-style: position tracked across edits)
local is_stale, stale_reason = true, nil
if patch.injection_marks and patch.injection_marks.start_mark and patch.injection_marks.end_mark then
local marks_mod = require("codetyper.core.marks")
if marks_mod.is_valid(patch.injection_marks.start_mark) and marks_mod.is_valid(patch.injection_marks.end_mark) then
is_stale = false
end
end
if is_stale then
is_stale, stale_reason = M.is_stale(patch)
end
if is_stale then
M.mark_stale(patch.id, stale_reason)
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "warning",
message = string.format("Patch %s is stale: %s", patch.id, stale_reason or "unknown"),
})
end)
logger.warn("patch", string.format("Patch %s is stale: %s", patch.id, stale_reason or "unknown"))
return false, "patch_stale: " .. (stale_reason or "unknown")
end
@@ -533,9 +576,29 @@ function M.apply(patch)
patch.target_bufnr = target_bufnr
end
-- Prepare code lines
-- Prepare code to inject (may be overwritten when SEARCH/REPLACE fails and we use REPLACE parts only)
local code_to_inject = patch.generated_code
local code_lines = vim.split(patch.generated_code, "\n", { plain = true })
-- Replace in-buffer thinking placeholder with actual code (if we inserted one when worker started).
-- Skip when patch uses SEARCH/REPLACE: that path needs the original buffer content and parses blocks itself.
local thinking_placeholder = require("codetyper.core.thinking_placeholder")
local ph = thinking_placeholder.get(patch.event_id)
if ph and ph.bufnr and vim.api.nvim_buf_is_valid(ph.bufnr)
and not (patch.use_search_replace and patch.search_replace_blocks and #patch.search_replace_blocks > 0) then
local marks_mod = require("codetyper.core.marks")
if marks_mod.is_valid(ph.start_mark) and marks_mod.is_valid(ph.end_mark) then
local sr, sc, er, ec = marks_mod.range_to_vim(ph.start_mark, ph.end_mark)
if sr ~= nil then
vim.api.nvim_buf_set_text(ph.bufnr, sr, sc, er, ec, code_lines)
thinking_placeholder.clear(patch.event_id)
M.mark_applied(patch.id)
return true
end
end
thinking_placeholder.clear(patch.event_id)
end
-- Use the stored inline prompt flag (computed during patch creation)
-- For inline prompts, we replace the tag region directly instead of separate remove + inject
local source_bufnr = patch.source_bufnr
@@ -622,15 +685,27 @@ function M.apply(patch)
return true, nil
else
-- SEARCH/REPLACE failed, log the error
-- SEARCH/REPLACE failed: use only REPLACE parts for fallback (never inject raw markers)
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "warning",
message = string.format("SEARCH/REPLACE failed: %s. Falling back to line-based injection.", err or "unknown"),
message = string.format("SEARCH/REPLACE failed: %s. Using REPLACE content only for injection.", err or "unknown"),
})
end)
-- Fall through to line-based injection as fallback
local replace_only = {}
for _, block in ipairs(patch.search_replace_blocks) do
if block.replace and block.replace ~= "" then
for _, line in ipairs(vim.split(block.replace, "\n", { plain = true })) do
table.insert(replace_only, line)
end
end
end
if #replace_only > 0 then
code_lines = replace_only
code_to_inject = table.concat(replace_only, "\n")
end
-- Fall through to line-based injection
end
end
@@ -638,6 +713,15 @@ function M.apply(patch)
local inject = get_inject_module()
local inject_result = nil
local has_range = patch.injection_range ~= nil
local apply_msg = string.format("apply: id=%s strategy=%s has_range=%s is_inline=%s target_bufnr=%s",
patch.id,
patch.injection_strategy or "nil",
tostring(has_range),
tostring(is_inline_prompt),
tostring(target_bufnr))
logger.info("patch", apply_msg)
-- Apply based on strategy using smart injection
local ok, err = pcall(function()
-- Prepare injection options
@@ -652,6 +736,28 @@ function M.apply(patch)
local start_line = patch.injection_range.start_line
local end_line = patch.injection_range.end_line
-- 99-style: use extmarks so we apply at current position (survives user typing)
local marks = require("codetyper.core.marks")
if patch.injection_marks and patch.injection_marks.start_mark and patch.injection_marks.end_mark then
local sm, em = patch.injection_marks.start_mark, patch.injection_marks.end_mark
if marks.is_valid(sm) and marks.is_valid(em) then
local sr, sc, er, ec = marks.range_to_vim(sm, em)
if sr ~= nil then
start_line = sr + 1
end_line = er + 1
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Applying at extmark range (lines %d-%d)", start_line, end_line),
})
end)
marks.delete(sm)
marks.delete(em)
end
end
end
-- For inline prompts, use scope range directly (tags are inside scope)
-- No adjustment needed since we didn't remove tags yet
if not is_inline_prompt and patch.scope and patch.scope.type then
@@ -733,8 +839,26 @@ function M.apply(patch)
end)
end
-- Diagnostic: log inject_opts before calling inject (why injection might not run)
local range_str = inject_opts.range
and string.format("%d-%d", inject_opts.range.start_line, inject_opts.range.end_line)
or "nil"
logger.info("patch", string.format(
"inject_opts: strategy=%s range=%s code_len=%d",
inject_opts.strategy or "nil",
range_str,
code_to_inject and #code_to_inject or 0
))
if not inject_opts.range then
logger.warn("patch", string.format(
"inject has no range (strategy=%s) - inject may append or skip",
tostring(patch.injection_strategy)
))
end
-- Use smart injection - handles imports automatically
inject_result = inject.inject(target_bufnr, patch.generated_code, inject_opts)
inject_result = inject.inject(target_bufnr, code_to_inject, inject_opts)
-- Log injection details
pcall(function()
@@ -759,10 +883,14 @@ function M.apply(patch)
end)
if not ok then
logger.error("patch", string.format("inject failed: %s", tostring(err)))
M.mark_rejected(patch.id, err)
return false, err
end
local body_lines = inject_result and inject_result.body_lines or "nil"
logger.info("patch", string.format("inject done: body_lines=%s", tostring(body_lines)))
M.mark_applied(patch.id)
pcall(function()
@@ -1081,13 +1209,17 @@ function M.flush_pending_smart()
for _, p in ipairs(patches) do
if p.status == "pending" then
logger.info("patch", string.format("flush trying: id=%s", p.id))
local success, err = M.smart_apply(p)
if success then
applied = applied + 1
logger.info("patch", string.format("flush result: id=%s success", p.id))
elseif err == "user_typing" then
deferred = deferred + 1
logger.info("patch", string.format("flush result: id=%s deferred (user_typing)", p.id))
else
stale = stale + 1
logger.info("patch", string.format("flush result: id=%s stale (%s)", p.id, tostring(err)))
end
end
end

View File

@@ -0,0 +1,117 @@
---@mod codetyper.core.marks Extmarks for tracking buffer positions (99-style)
---@brief [[
--- Positions survive user edits so we can apply patches at the right place
--- after the user has been typing while the request was "thinking".
---@brief ]]
local M = {}
local nsid = vim.api.nvim_create_namespace("codetyper.marks")
---@class Mark
---@field id number Extmark id
---@field buffer number Buffer number
---@field nsid number Namespace id
--- Create an extmark at (row_0, col_0). 0-based indexing for nvim API.
---@param buffer number
---@param row_0 number 0-based row
---@param col_0 number 0-based column
---@return Mark
function M.mark_point(buffer, row_0, col_0)
if not vim.api.nvim_buf_is_valid(buffer) then
return { id = nil, buffer = buffer, nsid = nsid }
end
local line_count = vim.api.nvim_buf_line_count(buffer)
if line_count == 0 or row_0 < 0 or row_0 >= line_count then
return { id = nil, buffer = buffer, nsid = nsid }
end
local id = vim.api.nvim_buf_set_extmark(buffer, nsid, row_0, col_0, {})
return {
id = id,
buffer = buffer,
nsid = nsid,
}
end
--- Create marks for a range. start/end are 1-based line numbers; end_col_0 is 0-based column on end line.
---@param buffer number
---@param start_line number 1-based start line
---@param end_line number 1-based end line
---@param end_col_0 number|nil 0-based column on end line (default: 0)
---@return Mark start_mark
---@return Mark end_mark
function M.mark_range(buffer, start_line, end_line, end_col_0)
end_col_0 = end_col_0 or 0
local start_mark = M.mark_point(buffer, start_line - 1, 0)
local end_mark = M.mark_point(buffer, end_line - 1, end_col_0)
return start_mark, end_mark
end
--- Get current 0-based (row, col) of a mark. Returns nil if mark invalid.
---@param mark Mark
---@return number|nil row_0
---@return number|nil col_0
function M.get_position(mark)
if not mark or not mark.id or not vim.api.nvim_buf_is_valid(mark.buffer) then
return nil, nil
end
local pos = vim.api.nvim_buf_get_extmark_by_id(mark.buffer, mark.nsid, mark.id, {})
if not pos or #pos < 2 then
return nil, nil
end
return pos[1], pos[2]
end
--- Check if mark still exists and buffer valid.
---@param mark Mark
---@return boolean
function M.is_valid(mark)
if not mark or not mark.id then
return false
end
local row, col = M.get_position(mark)
return row ~= nil and col ~= nil
end
--- Get current range as 0-based (start_row, start_col, end_row, end_col) for nvim_buf_set_text. Returns nil if any mark invalid.
---@param start_mark Mark
---@param end_mark Mark
---@return number|nil, number|nil, number|nil, number|nil
function M.range_to_vim(start_mark, end_mark)
local sr, sc = M.get_position(start_mark)
local er, ec = M.get_position(end_mark)
if sr == nil or er == nil then
return nil, nil, nil, nil
end
return sr, sc, er, ec
end
--- Replace text between two marks with lines (like 99 Range:replace_text). Uses current positions from extmarks.
---@param buffer number
---@param start_mark Mark
---@param end_mark Mark
---@param lines string[]
---@return boolean success
function M.replace_text(buffer, start_mark, end_mark, lines)
local sr, sc, er, ec = M.range_to_vim(start_mark, end_mark)
if sr == nil then
return false
end
if not vim.api.nvim_buf_is_valid(buffer) then
return false
end
vim.api.nvim_buf_set_text(buffer, sr, sc, er, ec, lines)
return true
end
--- Delete extmark (cleanup).
---@param mark Mark
function M.delete(mark)
if not mark or not mark.id or not vim.api.nvim_buf_is_valid(mark.buffer) then
return
end
pcall(vim.api.nvim_buf_del_extmark, mark.buffer, mark.nsid, mark.id)
end
return M

View File

@@ -12,6 +12,7 @@ local worker = require("codetyper.core.scheduler.worker")
local confidence_mod = require("codetyper.core.llm.confidence")
local context_modal = require("codetyper.adapters.nvim.ui.context_modal")
local params = require("codetyper.params.agents.scheduler")
local logger = require("codetyper.support.logger")
-- Setup context modal cleanup on exit
context_modal.setup()
@@ -226,8 +227,12 @@ end
---@param event table PromptEvent
---@param result table WorkerResult
local function handle_worker_result(event, result)
-- Clear 99-style inline "Thinking..." virtual text when worker finishes (any outcome)
require("codetyper.core.thinking_placeholder").clear_inline(event.id)
-- Check if LLM needs more context
if result.needs_context then
require("codetyper.core.thinking_placeholder").remove_on_failure(event.id)
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
@@ -325,6 +330,8 @@ local function handle_worker_result(event, result)
end
if not result.success then
-- Remove in-buffer placeholder on failure (will be re-inserted if we escalate/retry)
require("codetyper.core.thinking_placeholder").remove_on_failure(event.id)
-- Failed - try escalation if this was ollama
if result.worker_type == "ollama" and event.attempt_count < 2 then
pcall(function()
@@ -446,6 +453,19 @@ local function dispatch_next()
})
end)
-- Show thinking indicator: top-right window (always) + in-buffer or 99-style inline
local thinking = require("codetyper.adapters.nvim.ui.thinking")
thinking.ensure_shown()
local is_inline = event.target_path and not event.target_path:match("%.coder%.") and (event.bufnr == vim.fn.bufnr(event.target_path))
local thinking_placeholder = require("codetyper.core.thinking_placeholder")
if is_inline then
-- 99-style: virtual text "⠋ Thinking..." at selection (no buffer change, SEARCH/REPLACE safe)
thinking_placeholder.start_inline(event)
else
thinking_placeholder.insert(event)
end
-- Create worker
worker.create(event, provider, function(result)
vim.schedule(function()
@@ -463,36 +483,26 @@ function M.schedule_patch_flush()
vim.defer_fn(function()
-- Check if there are any pending patches
local pending = patch.get_pending()
logger.info("scheduler", string.format("schedule_patch_flush: %d pending", #pending))
if #pending == 0 then
waiting_to_flush = false
return -- Nothing to apply
end
local safe, reason = M.is_safe_to_inject()
logger.info("scheduler", string.format("is_safe_to_inject=%s (%s)", tostring(safe), tostring(reason or "ok")))
if safe then
waiting_to_flush = false
local applied, stale = patch.flush_pending_smart()
if applied > 0 or stale > 0 then
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Patches flushed: %d applied, %d stale", applied, stale),
})
end)
logger.info("scheduler", string.format("Patches flushed: %d applied, %d stale", applied, stale))
end
else
-- Not safe yet (user is typing), reschedule to try again
-- Only log once when we start waiting
if not waiting_to_flush then
waiting_to_flush = true
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Waiting for user to finish typing before applying code...",
})
end)
logger.info("scheduler", "Waiting for user to finish typing before applying code...")
end
-- Retry after a delay - keep waiting for user to finish typing
M.schedule_patch_flush()

View File

@@ -84,6 +84,25 @@ local function has_search_replace_blocks(response)
or response:match("%[SEARCH%]") ~= nil
end
--- Clean LLM response to extract only code
---@param response string Raw LLM response
---@param filetype string|nil File type for language detection
---@return string Cleaned code
--- Strip @thinking ... end thinking block; return only the code part for injection.
---@param text string Raw response that may start with @thinking ... end thinking
---@return string Text with thinking block removed (or original if no block)
local function strip_thinking_block(text)
if not text or text == "" then
return text or ""
end
-- Match from start: @thinking, any content, then line "end thinking"; capture everything after that
local after = text:match("^%s*@thinking[%s%S]*\nend thinking%s*\n(.*)")
if after then
return after:match("^%s*(.-)%s*$") or after
end
return text
end
--- Clean LLM response to extract only code
---@param response string Raw LLM response
---@param filetype string|nil File type for language detection
@@ -95,6 +114,9 @@ local function clean_response(response, filetype)
local cleaned = response
-- Remove @thinking ... end thinking block first (we show thinking in placeholder; inject only code)
cleaned = strip_thinking_block(cleaned)
-- Remove LLM special tokens (deepseek, llama, etc.)
cleaned = cleaned:gsub("<begin▁of▁sentence>", "")
cleaned = cleaned:gsub("<end▁of▁sentence>", "")
@@ -502,89 +524,55 @@ local function build_prompt(event)
system_prompt = intent_mod.get_prompt_modifier(event.intent)
end
-- Ask the LLM to show its thinking (so we can display it in the buffer)
system_prompt = system_prompt .. [[
OUTPUT FORMAT - Show your reasoning first:
1. Start with exactly this line: @thinking
2. Then write your reasoning (what you will do and why) on the following lines.
3. End the reasoning block with exactly this line: end thinking
4. Then output the code on the following lines.
Example:
@thinking
I will add a validation check because the user asked for it. I'll place it at the start of the function.
end thinking
<your code here>
]]
-- SPECIAL HANDLING: Inline prompts with /@ ... @/ tags
-- Uses SEARCH/REPLACE block format for reliable code editing
-- Output only the code that replaces the tagged region (no SEARCH/REPLACE markers)
if is_inline_prompt(event) and event.range and event.range.start_line then
local start_line = event.range.start_line
local end_line = event.range.end_line or start_line
-- Build full file content WITHOUT the /@ @/ tags for cleaner context
local file_content_clean = {}
for i, line in ipairs(target_lines) do
-- Skip lines that are part of the tag
if i < start_line or i > end_line then
table.insert(file_content_clean, line)
end
end
-- Full file content for context
local file_content = table.concat(target_lines, "\n"):sub(1, 12000)
user_prompt = string.format(
[[You are editing a %s file: %s
TASK: %s
FULL FILE CONTENT:
FULL FILE:
```%s
%s
```
IMPORTANT: The instruction above may ask you to make changes ANYWHERE in the file (e.g., "at the top", "after function X", etc.). Read the instruction carefully to determine WHERE to apply the change.
INSTRUCTIONS:
You MUST respond using SEARCH/REPLACE blocks. This format lets you precisely specify what to find and what to replace it with.
FORMAT:
<<<<<<< SEARCH
[exact lines to find in the file - copy them exactly including whitespace]
=======
[new lines to replace them with]
>>>>>>> REPLACE
RULES:
1. The SEARCH section must contain EXACT lines from the file (copy-paste them)
2. Include 2-3 context lines to uniquely identify the location
3. The REPLACE section contains the modified code
4. You can use multiple SEARCH/REPLACE blocks for multiple changes
5. Preserve the original indentation style
6. If adding new code at the start/end of file, include the first/last few lines in SEARCH
EXAMPLES:
Example 1 - Adding code at the TOP of file:
Task: "Add a comment at the top"
<<<<<<< SEARCH
// existing first line
// existing second line
=======
// NEW COMMENT ADDED HERE
// existing first line
// existing second line
>>>>>>> REPLACE
Example 2 - Modifying a function:
Task: "Add validation to setValue"
<<<<<<< SEARCH
export function setValue(key, value) {
cache.set(key, value);
}
=======
export function setValue(key, value) {
if (!key) throw new Error("key required");
cache.set(key, value);
}
>>>>>>> REPLACE
Now apply the requested changes using SEARCH/REPLACE blocks:]],
The user has selected lines %d-%d. Your output will REPLACE those lines exactly.
Output ONLY the new code for that region (no markers, no explanations, no code fences). Your response replaces the selection. Preserve indentation.]],
filetype,
vim.fn.fnamemodify(event.target_path or "", ":t"),
event.prompt_content,
filetype,
table.concat(file_content_clean, "\n"):sub(1, 8000) -- Limit size
file_content,
start_line,
end_line
)
context.system_prompt = system_prompt
context.formatted_prompt = user_prompt
context.is_inline_prompt = true
context.use_search_replace = true
return user_prompt, context
end

View File

@@ -0,0 +1,185 @@
---@mod codetyper.core.thinking_placeholder In-buffer gray "thinking" text
---@brief [[
--- Inserts @thinking .... end thinking at the injection line (grayed out),
--- then replace it with the actual code when the response arrives.
---@brief ]]
local M = {}
local marks = require("codetyper.core.marks")
local PLACEHOLDER_TEXT = "@thinking .... end thinking"
local ns_highlight = vim.api.nvim_create_namespace("codetyper.thinking_placeholder")
--- event_id -> { start_mark, end_mark, bufnr } for the placeholder line
local placeholders = {}
--- 99-style inline: event_id -> { bufnr, nsid, extmark_id, throbber } for virtual-text-only "Thinking..."
local ns_inline = vim.api.nvim_create_namespace("codetyper.thinking_inline")
local inline_status = {}
--- Insert gray placeholder at the injection range in the target buffer.
--- Replaces the range (prompt/scope) with one line "@thinking .... end thinking" and grays it out.
---@param event table PromptEvent with range, scope_range, target_path
---@return boolean success
function M.insert(event)
if not event or not event.range then
return false
end
local range = event.scope_range or event.range
local target_bufnr = vim.fn.bufnr(event.target_path)
if target_bufnr == -1 then
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(buf) == event.target_path then
target_bufnr = buf
break
end
end
end
if target_bufnr == -1 or not vim.api.nvim_buf_is_valid(target_bufnr) then
target_bufnr = vim.fn.bufadd(event.target_path)
if target_bufnr > 0 then
vim.fn.bufload(target_bufnr)
end
end
if target_bufnr <= 0 or not vim.api.nvim_buf_is_valid(target_bufnr) then
return false
end
local line_count = vim.api.nvim_buf_line_count(target_bufnr)
local end_line = range.end_line
-- Include next line if it's only "}" (or whitespace + "}") so we don't leave a stray closing brace
if end_line < line_count then
local next_line = vim.api.nvim_buf_get_lines(target_bufnr, end_line, end_line + 1, false)
if next_line and next_line[1] and next_line[1]:match("^%s*}$") then
end_line = end_line + 1
end
end
local start_row_0 = range.start_line - 1
local end_row_0 = end_line
-- Replace range with single placeholder line
vim.api.nvim_buf_set_lines(target_bufnr, start_row_0, end_row_0, false, { PLACEHOLDER_TEXT })
-- Gray out: extmark over the whole line
vim.api.nvim_buf_set_extmark(target_bufnr, ns_highlight, start_row_0, 0, {
end_row = start_row_0 + 1,
hl_group = "Comment",
hl_eol = true,
})
-- Store marks for this placeholder so patch can replace it
local start_mark = marks.mark_point(target_bufnr, start_row_0, 0)
local end_mark = marks.mark_point(target_bufnr, start_row_0, #PLACEHOLDER_TEXT)
placeholders[event.id] = {
start_mark = start_mark,
end_mark = end_mark,
bufnr = target_bufnr,
}
return true
end
--- Get placeholder marks for an event (so patch can replace that range with code).
---@param event_id string
---@return table|nil { start_mark, end_mark, bufnr } or nil
function M.get(event_id)
return placeholders[event_id]
end
--- Clear placeholder entry after applying (and optionally delete marks).
---@param event_id string
function M.clear(event_id)
local p = placeholders[event_id]
if p then
marks.delete(p.start_mark)
marks.delete(p.end_mark)
placeholders[event_id] = nil
end
end
--- Remove placeholder from buffer (e.g. on failure/cancel) and clear. Replaces placeholder line with empty line.
---@param event_id string
function M.remove_on_failure(event_id)
local p = placeholders[event_id]
if not p or not p.bufnr or not vim.api.nvim_buf_is_valid(p.bufnr) then
M.clear(event_id)
return
end
if marks.is_valid(p.start_mark) and marks.is_valid(p.end_mark) then
local sr, sc, er, ec = marks.range_to_vim(p.start_mark, p.end_mark)
if sr ~= nil then
vim.api.nvim_buf_set_text(p.bufnr, sr, sc, er, ec, { "" })
end
end
M.clear(event_id)
end
--- 99-style: show "⠋ Thinking..." 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)
if not event or not event.id or not event.range then
return
end
local range = event.range
local target_bufnr = vim.fn.bufnr(event.target_path)
if target_bufnr == -1 then
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(buf) == event.target_path then
target_bufnr = buf
break
end
end
end
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 col = 0
local extmark_id = vim.api.nvim_buf_set_extmark(target_bufnr, ns_inline, start_row_0, col, {
virt_lines = { { { " Implementing", "Comment" } } },
})
local Throbber = require("codetyper.adapters.nvim.ui.throbber")
local throb = Throbber.new(function(icon)
if not inline_status[event.id] then
return
end
local ent = inline_status[event.id]
if not ent.bufnr or not vim.api.nvim_buf_is_valid(ent.bufnr) then
return
end
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" } } },
})
if not ok then
M.clear_inline(event.id)
end
end)
inline_status[event.id] = {
bufnr = target_bufnr,
nsid = ns_inline,
extmark_id = extmark_id,
throbber = throb,
start_row_0 = start_row_0,
col = col,
}
throb:start()
end
--- Clear 99-style inline virtual text (call when worker completes).
---@param event_id string
function M.clear_inline(event_id)
local ent = inline_status[event_id]
if not ent then
return
end
if ent.throbber then
ent.throbber:stop()
end
if ent.bufnr and vim.api.nvim_buf_is_valid(ent.bufnr) and ent.extmark_id then
pcall(vim.api.nvim_buf_del_extmark, ent.bufnr, ns_inline, ent.extmark_id)
end
inline_status[event_id] = nil
end
return M

View File

@@ -76,6 +76,42 @@ function M.inject_code(target_path, code, prompt_type)
end)
end
--- Inject code with strategy and range (used by patch system)
---@param bufnr number Buffer number
---@param code string Generated code
---@param opts table|nil { strategy = "replace"|"insert"|"append", range = { start_line, end_line } (1-based) }
---@return table { imports_added: number, body_lines: number, imports_merged: boolean }
function M.inject(bufnr, code, opts)
opts = opts or {}
local strategy = opts.strategy or "replace"
local range = opts.range
local lines = vim.split(code, "\n", { plain = true })
local body_lines = #lines
if not vim.api.nvim_buf_is_valid(bufnr) then
return { imports_added = 0, body_lines = 0, imports_merged = false }
end
local line_count = vim.api.nvim_buf_line_count(bufnr)
if strategy == "replace" and range and range.start_line and range.end_line then
local start_0 = math.max(0, range.start_line - 1)
local end_0 = math.min(line_count, range.end_line)
if end_0 < start_0 then
end_0 = start_0
end
vim.api.nvim_buf_set_lines(bufnr, start_0, end_0, false, lines)
elseif strategy == "insert" and range and range.start_line then
local at_0 = math.max(0, math.min(range.start_line - 1, line_count))
vim.api.nvim_buf_set_lines(bufnr, at_0, at_0, false, lines)
else
-- append
vim.api.nvim_buf_set_lines(bufnr, line_count, line_count, false, lines)
end
return { imports_added = 0, body_lines = body_lines, imports_merged = false }
end
--- Inject code for refactor (replace entire file)
---@param bufnr number Buffer number
---@param code string Generated code

View File

@@ -1,11 +1,13 @@
---@mod codetyper.params.agents.scheduler Scheduler configuration
--- 99-style: multiple requests can run in parallel (thinking); user can keep typing.
--- Injection uses extmarks so position is preserved across edits.
local M = {}
M.config = {
enabled = true,
ollama_scout = true,
escalation_threshold = 0.7,
max_concurrent = 2,
max_concurrent = 5, -- Allow multiple in-flight requests (like 99); user can type while thinking
completion_delay_ms = 100,
apply_delay_ms = 5000, -- Wait before applying code
remote_provider = "copilot", -- Default fallback provider

View File

@@ -3,17 +3,26 @@
local M = {}
local utils = require("codetyper.support.utils")
local logger = require("codetyper.support.logger")
--- Get config with safe fallback
---@return table config
local function get_config_safe()
logger.func_entry("parser", "get_config_safe", {})
local ok, codetyper = pcall(require, "codetyper")
if ok and codetyper.get_config then
local config = codetyper.get_config()
if config and config.patterns then
logger.debug("parser", "get_config_safe: loaded config from codetyper")
logger.func_exit("parser", "get_config_safe", "success")
return config
end
end
logger.debug("parser", "get_config_safe: using fallback defaults")
logger.func_exit("parser", "get_config_safe", "fallback")
-- Fallback defaults
return {
patterns = {
@@ -29,6 +38,12 @@ end
---@param close_tag string Closing tag
---@return CoderPrompt[] List of found prompts
function M.find_prompts(content, open_tag, close_tag)
logger.func_entry("parser", "find_prompts", {
content_length = #content,
open_tag = open_tag,
close_tag = close_tag
})
local prompts = {}
local escaped_open = utils.escape_pattern(open_tag)
local escaped_close = utils.escape_pattern(close_tag)
@@ -38,11 +53,14 @@ function M.find_prompts(content, open_tag, close_tag)
local current_prompt = nil
local prompt_content = {}
logger.debug("parser", "find_prompts: parsing " .. #lines .. " lines")
for line_num, line in ipairs(lines) do
if not in_prompt then
-- Look for opening tag
local start_col = line:find(escaped_open)
if start_col then
logger.debug("parser", "find_prompts: found opening tag at line " .. line_num .. ", col " .. start_col)
in_prompt = true
current_prompt = {
start_line = line_num,
@@ -58,6 +76,7 @@ function M.find_prompts(content, open_tag, close_tag)
current_prompt.end_line = line_num
current_prompt.end_col = start_col + #open_tag + end_col + #close_tag - 2
table.insert(prompts, current_prompt)
logger.debug("parser", "find_prompts: single-line prompt completed at line " .. line_num)
in_prompt = false
current_prompt = nil
else
@@ -75,6 +94,7 @@ function M.find_prompts(content, open_tag, close_tag)
current_prompt.end_line = line_num
current_prompt.end_col = end_col + #close_tag - 1
table.insert(prompts, current_prompt)
logger.debug("parser", "find_prompts: multi-line prompt completed at line " .. line_num .. ", total lines: " .. #prompt_content)
in_prompt = false
current_prompt = nil
prompt_content = {}
@@ -84,6 +104,9 @@ function M.find_prompts(content, open_tag, close_tag)
end
end
logger.debug("parser", "find_prompts: found " .. #prompts .. " prompts total")
logger.func_exit("parser", "find_prompts", "found " .. #prompts .. " prompts")
return prompts
end
@@ -91,12 +114,19 @@ end
---@param bufnr number Buffer number
---@return CoderPrompt[] List of found prompts
function M.find_prompts_in_buffer(bufnr)
logger.func_entry("parser", "find_prompts_in_buffer", { bufnr = bufnr })
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
return M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag)
logger.debug("parser", "find_prompts_in_buffer: bufnr=" .. bufnr .. ", lines=" .. #lines .. ", content_length=" .. #content)
local result = M.find_prompts(content, config.patterns.open_tag, config.patterns.close_tag)
logger.func_exit("parser", "find_prompts_in_buffer", "found " .. #result .. " prompts")
return result
end
--- Get prompt at cursor position
@@ -108,21 +138,37 @@ function M.get_prompt_at_cursor(bufnr)
local line = cursor[1]
local col = cursor[2] + 1 -- Convert to 1-indexed
logger.func_entry("parser", "get_prompt_at_cursor", {
bufnr = bufnr,
line = line,
col = col
})
local prompts = M.find_prompts_in_buffer(bufnr)
for _, prompt in ipairs(prompts) do
logger.debug("parser", "get_prompt_at_cursor: checking " .. #prompts .. " prompts")
for i, prompt in ipairs(prompts) do
logger.debug("parser", "get_prompt_at_cursor: checking prompt " .. i .. " (lines " .. prompt.start_line .. "-" .. prompt.end_line .. ")")
if line >= prompt.start_line and line <= prompt.end_line then
logger.debug("parser", "get_prompt_at_cursor: cursor line " .. line .. " is within prompt line range")
if line == prompt.start_line and col < prompt.start_col then
logger.debug("parser", "get_prompt_at_cursor: cursor col " .. col .. " is before prompt start_col " .. prompt.start_col)
goto continue
end
if line == prompt.end_line and col > prompt.end_col then
logger.debug("parser", "get_prompt_at_cursor: cursor col " .. col .. " is after prompt end_col " .. prompt.end_col)
goto continue
end
logger.debug("parser", "get_prompt_at_cursor: found prompt at cursor")
logger.func_exit("parser", "get_prompt_at_cursor", "prompt found")
return prompt
end
::continue::
end
logger.debug("parser", "get_prompt_at_cursor: no prompt found at cursor")
logger.func_exit("parser", "get_prompt_at_cursor", nil)
return nil
end
@@ -131,12 +177,20 @@ end
---@return CoderPrompt|nil Last prompt or nil
function M.get_last_prompt(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "get_last_prompt", { bufnr = bufnr })
local prompts = M.find_prompts_in_buffer(bufnr)
if #prompts > 0 then
return prompts[#prompts]
local last = prompts[#prompts]
logger.debug("parser", "get_last_prompt: returning prompt at line " .. last.start_line)
logger.func_exit("parser", "get_last_prompt", "prompt at line " .. last.start_line)
return last
end
logger.debug("parser", "get_last_prompt: no prompts found")
logger.func_exit("parser", "get_last_prompt", nil)
return nil
end
@@ -144,18 +198,30 @@ end
---@param content string Prompt content
---@return "refactor" | "add" | "document" | "explain" | "generic" Prompt type
function M.detect_prompt_type(content)
logger.func_entry("parser", "detect_prompt_type", { content_preview = content:sub(1, 50) })
local lower = content:lower()
if lower:match("refactor") then
logger.debug("parser", "detect_prompt_type: detected 'refactor'")
logger.func_exit("parser", "detect_prompt_type", "refactor")
return "refactor"
elseif lower:match("add") or lower:match("create") or lower:match("implement") then
logger.debug("parser", "detect_prompt_type: detected 'add'")
logger.func_exit("parser", "detect_prompt_type", "add")
return "add"
elseif lower:match("document") or lower:match("comment") or lower:match("jsdoc") then
logger.debug("parser", "detect_prompt_type: detected 'document'")
logger.func_exit("parser", "detect_prompt_type", "document")
return "document"
elseif lower:match("explain") or lower:match("what") or lower:match("how") then
logger.debug("parser", "detect_prompt_type: detected 'explain'")
logger.func_exit("parser", "detect_prompt_type", "explain")
return "explain"
end
logger.debug("parser", "detect_prompt_type: detected 'generic'")
logger.func_exit("parser", "detect_prompt_type", "generic")
return "generic"
end
@@ -163,10 +229,16 @@ end
---@param content string Raw prompt content
---@return string Cleaned content
function M.clean_prompt(content)
logger.func_entry("parser", "clean_prompt", { content_length = #content })
-- Trim leading/trailing whitespace
content = content:match("^%s*(.-)%s*$")
-- Normalize multiple newlines
content = content:gsub("\n\n\n+", "\n\n")
logger.debug("parser", "clean_prompt: cleaned from " .. #content .. " chars")
logger.func_exit("parser", "clean_prompt", "length=" .. #content)
return content
end
@@ -175,7 +247,14 @@ end
---@param close_tag string Closing tag
---@return boolean
function M.has_closing_tag(line, close_tag)
return line:find(utils.escape_pattern(close_tag)) ~= nil
logger.func_entry("parser", "has_closing_tag", { line_preview = line:sub(1, 30), close_tag = close_tag })
local result = line:find(utils.escape_pattern(close_tag)) ~= nil
logger.debug("parser", "has_closing_tag: result=" .. tostring(result))
logger.func_exit("parser", "has_closing_tag", result)
return result
end
--- Check if buffer has any unclosed prompts
@@ -183,6 +262,9 @@ end
---@return boolean
function M.has_unclosed_prompts(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "has_unclosed_prompts", { bufnr = bufnr })
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
@@ -194,7 +276,12 @@ function M.has_unclosed_prompts(bufnr)
local _, open_count = content:gsub(escaped_open, "")
local _, close_count = content:gsub(escaped_close, "")
return open_count > close_count
local has_unclosed = open_count > close_count
logger.debug("parser", "has_unclosed_prompts: open=" .. open_count .. ", close=" .. close_count .. ", unclosed=" .. tostring(has_unclosed))
logger.func_exit("parser", "has_unclosed_prompts", has_unclosed)
return has_unclosed
end
--- Extract file references from prompt content
@@ -202,6 +289,8 @@ end
---@param content string Prompt content
---@return string[] List of file references
function M.extract_file_references(content)
logger.func_entry("parser", "extract_file_references", { content_length = #content })
local files = {}
-- Pattern: @ followed by word char, dot, underscore, or dash as FIRST char
-- Then optionally more path characters including /
@@ -209,8 +298,13 @@ function M.extract_file_references(content)
for file in content:gmatch("@([%w%._%-][%w%._%-/]*)") do
if file ~= "" then
table.insert(files, file)
logger.debug("parser", "extract_file_references: found file reference: " .. file)
end
end
logger.debug("parser", "extract_file_references: found " .. #files .. " file references")
logger.func_exit("parser", "extract_file_references", files)
return files
end
@@ -218,9 +312,16 @@ end
---@param content string Prompt content
---@return string Cleaned content without file references
function M.strip_file_references(content)
logger.func_entry("parser", "strip_file_references", { content_length = #content })
-- Remove @filename patterns but preserve @/ closing tag
-- Pattern requires first char after @ to be word char, dot, underscore, or dash (NOT /)
return content:gsub("@([%w%._%-][%w%._%-/]*)", "")
local result = content:gsub("@([%w%._%-][%w%._%-/]*)", "")
logger.debug("parser", "strip_file_references: stripped " .. (#content - #result) .. " chars")
logger.func_exit("parser", "strip_file_references", "length=" .. #result)
return result
end
--- Check if cursor is inside an unclosed prompt tag
@@ -229,6 +330,9 @@ end
---@return number|nil start_line Line where the open tag starts
function M.is_cursor_in_open_tag(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "is_cursor_in_open_tag", { bufnr = bufnr })
local config = get_config_safe()
local cursor = vim.api.nvim_win_get_cursor(0)
@@ -247,14 +351,20 @@ function M.is_cursor_in_open_tag(bufnr)
for _ in line:gmatch(escaped_open) do
open_count = open_count + 1
last_open_line = line_num
logger.debug("parser", "is_cursor_in_open_tag: found open tag at line " .. line_num)
end
-- Count closes on this line
for _ in line:gmatch(escaped_close) do
close_count = close_count + 1
logger.debug("parser", "is_cursor_in_open_tag: found close tag at line " .. line_num)
end
end
local is_inside = open_count > close_count
logger.debug("parser", "is_cursor_in_open_tag: open=" .. open_count .. ", close=" .. close_count .. ", is_inside=" .. tostring(is_inside) .. ", last_open_line=" .. tostring(last_open_line))
logger.func_exit("parser", "is_cursor_in_open_tag", { is_inside = is_inside, last_open_line = last_open_line })
return is_inside, is_inside and last_open_line or nil
end
@@ -263,10 +373,14 @@ end
---@return string|nil prefix The text after @ being typed, or nil if not typing a file ref
function M.get_file_ref_prefix(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
logger.func_entry("parser", "get_file_ref_prefix", { bufnr = bufnr })
local cursor = vim.api.nvim_win_get_cursor(0)
local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1]
if not line then
logger.debug("parser", "get_file_ref_prefix: no line at cursor")
logger.func_exit("parser", "get_file_ref_prefix", nil)
return nil
end
@@ -279,10 +393,17 @@ function M.get_file_ref_prefix(bufnr)
-- Make sure it's not the closing tag pattern
if prefix and before_cursor:sub(-2) == "@/" then
logger.debug("parser", "get_file_ref_prefix: closing tag detected, returning nil")
logger.func_exit("parser", "get_file_ref_prefix", nil)
return nil
end
logger.debug("parser", "get_file_ref_prefix: prefix=" .. tostring(prefix))
logger.func_exit("parser", "get_file_ref_prefix", prefix)
return prefix
end
logger.info("parser", "Parser module loaded")
return M

View File

@@ -0,0 +1,221 @@
---@mod codetyper.support.logger Structured logging utility for Codetyper.nvim
local M = {}
-- Get the codetyper logger instance
local logger = nil
local function get_logger()
if logger then
return logger
end
-- Try to get codetyper module for config
local ok, codetyper = pcall(require, "codetyper")
local config = {}
if ok and codetyper.get_config then
config = codetyper.get_config() or {}
end
-- Use ~/.config/nvim/logs/ directory
local log_dir = vim.fn.expand("~/.config/nvim/logs")
vim.fn.mkdir(log_dir, "p")
logger = {
debug_enabled = config.debug_logging or false,
log_file = config.log_file or log_dir .. "/codetyper.log",
}
return logger
end
--- Get current timestamp
---@return string timestamp ISO 8601 format
local function get_timestamp()
return os.date("%Y-%m-%d %H:%M:%S")
end
--- Get calling function info
---@return string caller_info
local function get_caller_info()
local info = debug.getinfo(3, "Sn")
if not info then
return "unknown"
end
local name = info.name or "anonymous"
local source = info.source and info.source:gsub("^@", "") or "unknown"
local line = info.linedefined or 0
return string.format("%s:%d [%s]", source, line, name)
end
--- Format log message
---@param level string Log level
---@param module string Module name
---@param message string Log message
---@return string formatted
local function format_log(level, module, message)
local timestamp = get_timestamp()
local caller = get_caller_info()
return string.format("[%s] [%s] [%s] %s | %s", timestamp, level, module, caller, message)
end
--- Write log to file
---@param message string Log message
local function write_to_file(message)
local log = get_logger()
local f = io.open(log.log_file, "a")
if f then
f:write(message .. "\n")
f:close()
end
end
--- Log debug message
---@param module string Module name
---@param message string Log message
function M.debug(module, message)
local log = get_logger()
if not log.debug_enabled then
return
end
local formatted = format_log("DEBUG", module, message)
write_to_file(formatted)
-- Also use vim.notify for visibility
vim.notify("[codetyper] " .. message, vim.log.levels.DEBUG)
end
--- Log info message
---@param module string Module name
---@param message string Log message
function M.info(module, message)
local formatted = format_log("INFO", module, message)
write_to_file(formatted)
vim.notify("[codetyper] " .. message, vim.log.levels.INFO)
end
--- Log warning message
---@param module string Module name
---@param message string Log message
function M.warn(module, message)
local formatted = format_log("WARN", module, message)
write_to_file(formatted)
vim.notify("[codetyper] " .. message, vim.log.levels.WARN)
end
--- Log error message
---@param module string Module name
---@param message string Log message
function M.error(module, message)
local formatted = format_log("ERROR", module, message)
write_to_file(formatted)
vim.notify("[codetyper] " .. message, vim.log.levels.ERROR)
end
--- Log function entry with parameters
---@param module string Module name
---@param func_name string Function name
---@param params table|nil Parameters (will be inspected)
function M.func_entry(module, func_name, params)
local log = get_logger()
if not log.debug_enabled then
return
end
local param_str = ""
if params then
local parts = {}
for k, v in pairs(params) do
local val_str = tostring(v)
if #val_str > 50 then
val_str = val_str:sub(1, 47) .. "..."
end
table.insert(parts, k .. "=" .. val_str)
end
param_str = table.concat(parts, ", ")
end
local message = string.format("ENTER %s(%s)", func_name, param_str)
M.debug(module, message)
end
--- Log function exit with return value
---@param module string Module name
---@param func_name string Function name
---@param result any Return value (will be inspected)
function M.func_exit(module, func_name, result)
local log = get_logger()
if not log.debug_enabled then
return
end
local result_str = tostring(result)
if type(result) == "table" then
result_str = vim.inspect(result)
end
if #result_str > 100 then
result_str = result_str:sub(1, 97) .. "..."
end
local message = string.format("EXIT %s -> %s", func_name, result_str)
M.debug(module, message)
end
--- Enable or disable debug logging
---@param enabled boolean
function M.set_debug(enabled)
local log = get_logger()
log.debug_enabled = enabled
M.info("logger", "Debug logging " .. (enabled and "enabled" or "disabled"))
end
--- Get log file path
---@return string log_file path
function M.get_log_file()
local log = get_logger()
return log.log_file
end
--- Clear log file
function M.clear()
local log = get_logger()
local f = io.open(log.log_file, "w")
if f then
f:write("")
f:close()
end
M.info("logger", "Log file cleared")
end
--- Show logs in a buffer
function M.show()
local log = get_logger()
local lines = {}
local f = io.open(log.log_file, "r")
if f then
for line in f:lines() do
table.insert(lines, line)
end
f:close()
end
-- Create a new buffer
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].filetype = "log"
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].readonly = true
-- Open in a split
vim.cmd("vsplit")
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, bufnr)
return bufnr
end
return M

View File

@@ -127,11 +127,36 @@ function M.ensure_dir(dirpath)
return true
end
--- Notify user with proper formatting
--- Notify user with proper formatting and log to file
---@param msg string Message to display
---@param level? number Vim log level (default: INFO)
function M.notify(msg, level)
level = level or vim.log.levels.INFO
-- Also log to file
local logger = require("codetyper.support.logger")
local level_name = "INFO"
if level == vim.log.levels.DEBUG then
level_name = "DEBUG"
elseif level == vim.log.levels.WARN then
level_name = "WARN"
elseif level == vim.log.levels.ERROR then
level_name = "ERROR"
end
-- Write to log file
local log_dir = vim.fn.expand("~/.config/nvim/logs")
vim.fn.mkdir(log_dir, "p")
local log_file = log_dir .. "/codetyper.log"
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
local log_entry = string.format("[%s] [%s] [utils.notify] %s\n", timestamp, level_name, msg)
local f = io.open(log_file, "a")
if f then
f:write(log_entry)
f:close()
end
vim.notify("[Codetyper] " .. msg, level)
end