From 4463a8144db1b0fc101337f2760ea375ed291958 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Mon, 16 Feb 2026 18:06:21 -0500 Subject: [PATCH] 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 --- doc/codetyper.txt | 5 - doc/tags | 41 +++ lua/codetyper/adapters/nvim/autocmds.lua | 292 +++++++++++------- lua/codetyper/adapters/nvim/commands.lua | 324 +++++++++++++++----- lua/codetyper/adapters/nvim/ui/thinking.lua | 171 +++++++++++ lua/codetyper/adapters/nvim/ui/throbber.lua | 87 ++++++ lua/codetyper/core/diff/patch.lua | 164 +++++++++- lua/codetyper/core/marks.lua | 117 +++++++ lua/codetyper/core/scheduler/scheduler.lua | 38 ++- lua/codetyper/core/scheduler/worker.lua | 106 +++---- lua/codetyper/core/thinking_placeholder.lua | 185 +++++++++++ lua/codetyper/inject.lua | 36 +++ lua/codetyper/params/agents/scheduler.lua | 4 +- lua/codetyper/parser.lua | 133 +++++++- lua/codetyper/support/logger.lua | 221 +++++++++++++ lua/codetyper/support/utils.lua | 27 +- 16 files changed, 1668 insertions(+), 283 deletions(-) create mode 100644 doc/tags create mode 100644 lua/codetyper/adapters/nvim/ui/thinking.lua create mode 100644 lua/codetyper/adapters/nvim/ui/throbber.lua create mode 100644 lua/codetyper/core/marks.lua create mode 100644 lua/codetyper/core/thinking_placeholder.lua create mode 100644 lua/codetyper/support/logger.lua diff --git a/doc/codetyper.txt b/doc/codetyper.txt index 9085eff..a78ddbe 100644 --- a/doc/codetyper.txt +++ b/doc/codetyper.txt @@ -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. diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..296ec17 --- /dev/null +++ b/doc/tags @@ -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* diff --git a/lua/codetyper/adapters/nvim/autocmds.lua b/lua/codetyper/adapters/nvim/autocmds.lua index 56f6482..3a70454 100644 --- a/lua/codetyper/adapters/nvim/autocmds.lua +++ b/lua/codetyper/adapters/nvim/autocmds.lua @@ -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 = "" 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) diff --git a/lua/codetyper/adapters/nvim/commands.lua b/lua/codetyper/adapters/nvim/commands.lua index 6ed6851..119b33f 100644 --- a/lua/codetyper/adapters/nvim/commands.lua +++ b/lua/codetyper/adapters/nvim/commands.lua @@ -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 + 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", "", submit_prompt, map_opts) + vim.keymap.set("n", "", submit_prompt, map_opts) + vim.keymap.set("n", "", submit_prompt, map_opts) + vim.keymap.set("n", "w", "w", vim.tbl_extend("force", map_opts, { desc = "Submit prompt" })) + -- Insert mode: Ctrl+Enter to submit + vim.keymap.set("i", "", submit_prompt, map_opts) + vim.keymap.set("i", "", submit_prompt, map_opts) + -- Close/cancel: Esc (in normal), q, or :q + vim.keymap.set("n", "", 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", "ctt", ":CoderTransformVisual", { + -- Visual mode: transform selection with custom prompt input + vim.keymap.set("v", "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", "ctt", "CoderTransformCursor", { + -- Normal mode: prompt only (no selection); request is entered in the prompt + vim.keymap.set("n", "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", "ctT", "CoderTransform", { - silent = true, - desc = "Coder: Transform all tags in file", - }) - - -- Index keymap - open coder companion - vim.keymap.set("n", "ci", "CoderIndex", { - silent = true, - desc = "Coder: Open coder companion for file", + desc = "Coder: Transform with prompt (no selection)", }) end diff --git a/lua/codetyper/adapters/nvim/ui/thinking.lua b/lua/codetyper/adapters/nvim/ui/thinking.lua new file mode 100644 index 0000000..a71ea50 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/thinking.lua @@ -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 diff --git a/lua/codetyper/adapters/nvim/ui/throbber.lua b/lua/codetyper/adapters/nvim/ui/throbber.lua new file mode 100644 index 0000000..929c447 --- /dev/null +++ b/lua/codetyper/adapters/nvim/ui/throbber.lua @@ -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 diff --git a/lua/codetyper/core/diff/patch.lua b/lua/codetyper/core/diff/patch.lua index 232a6dd..2d0b477 100644 --- a/lua/codetyper/core/diff/patch.lua +++ b/lua/codetyper/core/diff/patch.lua @@ -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 diff --git a/lua/codetyper/core/marks.lua b/lua/codetyper/core/marks.lua new file mode 100644 index 0000000..6cf9666 --- /dev/null +++ b/lua/codetyper/core/marks.lua @@ -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 diff --git a/lua/codetyper/core/scheduler/scheduler.lua b/lua/codetyper/core/scheduler/scheduler.lua index c002369..3511f02 100644 --- a/lua/codetyper/core/scheduler/scheduler.lua +++ b/lua/codetyper/core/scheduler/scheduler.lua @@ -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() diff --git a/lua/codetyper/core/scheduler/worker.lua b/lua/codetyper/core/scheduler/worker.lua index 103c4c1..a0ae87e 100644 --- a/lua/codetyper/core/scheduler/worker.lua +++ b/lua/codetyper/core/scheduler/worker.lua @@ -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 + +]] + -- 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 diff --git a/lua/codetyper/core/thinking_placeholder.lua b/lua/codetyper/core/thinking_placeholder.lua new file mode 100644 index 0000000..a4158fa --- /dev/null +++ b/lua/codetyper/core/thinking_placeholder.lua @@ -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 diff --git a/lua/codetyper/inject.lua b/lua/codetyper/inject.lua index 886c7e6..24f22b8 100644 --- a/lua/codetyper/inject.lua +++ b/lua/codetyper/inject.lua @@ -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 diff --git a/lua/codetyper/params/agents/scheduler.lua b/lua/codetyper/params/agents/scheduler.lua index 4936b17..1c9d135 100644 --- a/lua/codetyper/params/agents/scheduler.lua +++ b/lua/codetyper/params/agents/scheduler.lua @@ -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 diff --git a/lua/codetyper/parser.lua b/lua/codetyper/parser.lua index 4be6a87..ec91666 100644 --- a/lua/codetyper/parser.lua +++ b/lua/codetyper/parser.lua @@ -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 diff --git a/lua/codetyper/support/logger.lua b/lua/codetyper/support/logger.lua new file mode 100644 index 0000000..3071012 --- /dev/null +++ b/lua/codetyper/support/logger.lua @@ -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 diff --git a/lua/codetyper/support/utils.lua b/lua/codetyper/support/utils.lua index 5020a98..01fa06a 100644 --- a/lua/codetyper/support/utils.lua +++ b/lua/codetyper/support/utils.lua @@ -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