From 60577f8951bde011f1d3cbd00cde10c2216a5f95 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Fri, 16 Jan 2026 09:00:35 -0500 Subject: [PATCH] feat: add conflict resolution, linter validation, and SEARCH/REPLACE system - Add git-style conflict resolution with visual diff highlighting - Add buffer-local keymaps: co/ct/cb/cn for conflict resolution - Add floating menu with auto-show after code injection - Add linter validation that auto-checks LSP diagnostics after accepting code - Add SEARCH/REPLACE block parsing with fuzzy matching - Add new commands: CoderConflictMenu, CoderLintCheck, CoderLintFix - Update README with complete keymaps reference and issue reporting guide - Update CHANGELOG and llms.txt with full documentation - Clean up code comments and documentation Co-Authored-By: Claude --- CHANGELOG.md | 173 ++-- README.md | 778 +++++++++------- doc/codetyper.txt | 2 +- llms.txt | 561 ++++++------ lua/codetyper/agent/agentic.lua | 2 +- lua/codetyper/agent/conflict.lua | 1071 +++++++++++++++++++++++ lua/codetyper/agent/context_builder.lua | 302 +++++++ lua/codetyper/agent/context_modal.lua | 206 ++++- lua/codetyper/agent/diff_review.lua | 384 ++++++++ lua/codetyper/agent/executor.lua | 132 ++- lua/codetyper/agent/init.lua | 157 +++- lua/codetyper/agent/intent.lua | 5 + lua/codetyper/agent/linter.lua | 431 +++++++++ lua/codetyper/agent/logs.lua | 152 +++- lua/codetyper/agent/loop.lua | 2 +- lua/codetyper/agent/patch.lua | 400 ++++++++- lua/codetyper/agent/resume.lua | 155 ++++ lua/codetyper/agent/scheduler.lua | 198 ++++- lua/codetyper/agent/scope.lua | 25 +- lua/codetyper/agent/search_replace.lua | 570 ++++++++++++ lua/codetyper/agent/tools.lua | 7 + lua/codetyper/agent/tools/edit.lua | 2 +- lua/codetyper/agent/tools/init.lua | 2 +- lua/codetyper/agent/ui.lua | 186 +++- lua/codetyper/agent/worker.lua | 165 ++++ lua/codetyper/ask.lua | 128 ++- lua/codetyper/autocmds.lua | 305 ++++--- lua/codetyper/cmp_source/init.lua | 56 ++ lua/codetyper/commands.lua | 428 ++++----- lua/codetyper/config.lua | 2 +- lua/codetyper/cost.lua | 2 +- lua/codetyper/credentials.lua | 82 +- lua/codetyper/gitignore.lua | 48 +- lua/codetyper/llm/copilot.lua | 24 +- lua/codetyper/prompts/agent.lua | 123 +-- lua/codetyper/tree.lua | 10 +- lua/codetyper/utils.lua | 71 ++ 37 files changed, 6107 insertions(+), 1240 deletions(-) create mode 100644 lua/codetyper/agent/conflict.lua create mode 100644 lua/codetyper/agent/context_builder.lua create mode 100644 lua/codetyper/agent/diff_review.lua create mode 100644 lua/codetyper/agent/linter.lua create mode 100644 lua/codetyper/agent/resume.lua create mode 100644 lua/codetyper/agent/search_replace.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 985727f..e56064a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-01-16 + +### Added + +- **Conflict Resolution System** - Git-style diff visualization for code review + - New `conflict.lua` module with full conflict management + - Git-style markers: `<<<<<<< CURRENT`, `=======`, `>>>>>>> INCOMING` + - Visual highlighting: green for original, blue for AI suggestions + - Buffer-local keymaps: `co` (ours), `ct` (theirs), `cb` (both), `cn` (none) + - Navigation keymaps: `]x` (next), `[x` (previous) + - Floating menu with `cm` or `` on conflict + - Number keys `1-4` for quick selection in menu + - Auto-show menu after code injection + - Auto-show menu for next conflict after resolution + - Commands: `:CoderConflictToggle`, `:CoderConflictMenu`, `:CoderConflictNext`, `:CoderConflictPrev`, `:CoderConflictStatus`, `:CoderConflictResolveAll`, `:CoderConflictAcceptCurrent`, `:CoderConflictAcceptIncoming`, `:CoderConflictAcceptBoth`, `:CoderConflictAcceptNone`, `:CoderConflictAutoMenu` + +- **Linter Validation System** - Auto-check and fix lint errors after code injection + - New `linter.lua` module for LSP diagnostics integration + - Auto-saves file after code injection + - Waits for LSP diagnostics to update + - Detects errors and warnings in injected code region + - Auto-queues AI fix prompts for lint errors + - Shows errors in quickfix list + - Commands: `:CoderLintCheck`, `:CoderLintFix`, `:CoderLintQuickfix`, `:CoderLintToggleAuto` + +- **SEARCH/REPLACE Block System** - Reliable code editing with fuzzy matching + - New `search_replace.lua` module for reliable code editing + - Parses SEARCH/REPLACE blocks from LLM responses + - Fuzzy matching with configurable thresholds + - Whitespace normalization for better matching + - Multiple matching strategies: exact, normalized, line-by-line + - Automatic fallback to line-based injection + +- **Process and Show Menu Function** - Streamlined conflict handling + - New `process_and_show_menu()` function combines processing and menu display + - Ensures highlights and keymaps are set up before showing menu + +### Changed + +- Unified automatic and manual tag processing to use same code path +- `insert_conflict()` now only inserts markers, callers handle processing +- Added `nowait = true` to conflict keymaps to prevent delay from built-in `c` command +- Improved patch application flow with conflict mode integration + +### Fixed + +- Fixed `string.gsub` returning two values causing `table.insert` errors +- Fixed keymaps not triggering due to Neovim's `c` command intercepting first character +- Fixed menu not showing after code injection +- Fixed diff highlighting not appearing + +--- + ## [0.5.0] - 2026-01-15 ### Added @@ -25,14 +78,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Notifies user of provider switch - **Enhanced Error Handling** - Better error messages for API failures - - Shows actual API response on parse errors (not generic "failed to parse") + - Shows actual API response on parse errors - Improved rate limit detection and messaging - - Sanitized newlines in error notifications to prevent UI crashes + - Sanitized newlines in error notifications - **Agent Tools System Improvements** - New `to_openai_format()` and `to_claude_format()` functions - `get_definitions()` for generic tool access - - Fixed tool call argument serialization (JSON strings vs tables) + - Fixed tool call argument serialization - **Credentials Management System** - Store API keys outside of config files - New `:CoderAddApiKey` command for interactive credential setup @@ -40,8 +93,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `:CoderCredentials` to view credential status - `:CoderSwitchProvider` to switch active LLM provider - Credentials stored in `~/.local/share/nvim/codetyper/configuration.json` - - Priority: stored credentials > config > environment variables - - Supports all providers: Claude, OpenAI, Gemini, Copilot, Ollama ### Changed @@ -54,7 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed "Failed to parse Copilot response" error showing instead of actual error - Fixed `nvim_buf_set_lines` crash from newlines in error messages - Fixed `tools.definitions` nil error in agent initialization -- Fixed tool name mismatch in agent prompts (write_file vs write) +- Fixed tool name mismatch in agent prompts --- @@ -63,7 +114,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Event-Driven Architecture** - Complete rewrite of prompt processing system - - Prompts are now treated as events with metadata (buffer state, priority, timestamps) + - Prompts are now treated as events with metadata - New modules: `queue.lua`, `patch.lua`, `confidence.lua`, `worker.lua`, `scheduler.lua` - Priority-based event queue with observer pattern - Buffer snapshots for staleness detection @@ -74,42 +125,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Configurable escalation threshold (default: 0.7) - **Confidence Scoring** - Response quality heuristics - - 5 weighted heuristics: length, uncertainty phrases, syntax completeness, repetition, truncation + - 5 weighted heuristics: length, uncertainty, syntax, repetition, truncation - Scores range from 0.0-1.0 - Determines whether to escalate to more capable LLM - **Staleness Detection** - Safe patch application - Track `vim.b.changedtick` and content hash at prompt time - Discard patches if buffer changed during generation - - Prevents stale code injection - **Completion-Aware Injection** - No fighting with autocomplete - Defer code injection while completion popup visible - Works with native popup, nvim-cmp, and coq_nvim - - Configurable delay after popup closes (default: 100ms) - **Tree-sitter Scope Resolution** - Smart context extraction - Automatically resolves prompts to enclosing function/method/class - Falls back to heuristics when Tree-sitter unavailable - - Scope types: function, method, class, block, file - **Intent Detection** - Understands what you want - Parses prompts to detect: complete, refactor, fix, add, document, test, optimize, explain - - Intent determines injection strategy (replace vs insert vs append) - - Priority adjustment based on intent type - -- **Tag Precedence Rules** - Multiple tags handled cleanly - - First tag in scope wins (FIFO ordering) - - Later tags in same scope skipped with warning - - Different scopes process independently + - Intent determines injection strategy ### Configuration New `scheduler` configuration block: ```lua scheduler = { - enabled = true, -- Enable event-driven mode - ollama_scout = true, -- Use Ollama first + enabled = true, + ollama_scout = true, escalation_threshold = 0.7, max_concurrent = 2, completion_delay_ms = 100, @@ -122,50 +164,32 @@ scheduler = { ### Added -- **Multiple LLM Providers** - Support for additional providers beyond Claude and Ollama - - OpenAI API with custom endpoint support (Azure, OpenRouter, etc.) +- **Multiple LLM Providers** - Support for additional providers + - OpenAI API with custom endpoint support - Google Gemini API - - GitHub Copilot (uses existing copilot.lua/copilot.vim authentication) + - GitHub Copilot - **Agent Mode** - Autonomous coding assistant with tool use - - `read_file` - Read file contents - - `edit_file` - Edit files with find/replace - - `write_file` - Create or overwrite files - - `bash` - Execute shell commands + - `read_file`, `edit_file`, `write_file`, `bash` tools - Real-time logging of agent actions - `:CoderAgent`, `:CoderAgentToggle`, `:CoderAgentStop` commands -- **Transform Commands** - Transform /@ @/ tags inline without split view - - `:CoderTransform` - Transform all tags in file - - `:CoderTransformCursor` - Transform tag at cursor - - `:CoderTransformVisual` - Transform selected tags - - Default keymaps: `ctt` (cursor/visual), `ctT` (all) +- **Transform Commands** - Transform /@ @/ tags inline + - `:CoderTransform`, `:CoderTransformCursor`, `:CoderTransformVisual` + - Default keymaps: `ctt`, `ctT` - **Auto-Index Feature** - Automatically create coder companion files - Creates `.coder.` companion files when opening source files - - Language-aware templates with correct comment syntax - - `:CoderIndex` command to manually open companion - - `ci` keymap - - Configurable via `auto_index` option (disabled by default) + - Language-aware templates - **Logs Panel** - Real-time visibility into LLM operations - - Token usage tracking (prompt and completion tokens) - - "Thinking" process visibility - - Request/response logging - - `:CoderLogs` command to toggle panel - **Mode Switcher** - Switch between Ask and Agent modes - - `:CoderType` command shows mode selection UI ### Changed -- Window width configuration now uses percentage as whole number (e.g., `25` for 25%) +- Window width configuration now uses percentage as whole number - Improved code extraction from LLM responses -- Better prompt templates for code generation - -### Fixed - -- Window width calculation consistency across modules --- @@ -174,31 +198,23 @@ scheduler = { ### Added - **Ask Panel** - Chat interface for asking questions about code - - Fixed at 1/4 (25%) screen width for consistent layout - - File attachment with `@` key (uses Telescope if available) - - `Ctrl+n` to start a new chat (clears input and history) + - Fixed at 1/4 (25%) screen width + - File attachment with `@` key + - `Ctrl+n` to start a new chat - `Ctrl+Enter` to submit questions - `Ctrl+f` to add current file as context - - `Ctrl+h/j/k/l` for window navigation - - `K/J` to jump between output and input windows - - `Y` to copy last response to clipboard - - `q` to close panel (closes both windows together) -- Auto-open Ask panel on startup (configurable via `auto_open_ask`) -- File content is now sent to LLM when attaching files with `@` + - `Y` to copy last response ### Changed -- Ask panel width is now fixed at 25% (1/4 of screen) -- Improved close behavior - closing either Ask window closes both -- Proper focus management after closing Ask panel -- Compact UI elements to fit 1/4 width layout -- Changed "Assistant" label to "AI" in chat messages +- Ask panel width is now fixed at 25% +- Improved close behavior +- Changed "Assistant" label to "AI" ### Fixed - Ask panel window state sync issues -- Window focus returning to code after closing Ask panel -- NerdTree/nvim-tree causing Ask panel to resize incorrectly +- Window focus returning to code after closing --- @@ -210,27 +226,13 @@ scheduler = { - Core plugin architecture with modular Lua structure - Split window view for coder and target files - Tag-based prompt system (`/@` to open, `@/` to close) -- Claude API integration for code generation -- Ollama API integration for local LLM support -- Automatic `.gitignore` management for coder files and `.coder/` folder -- Smart prompt type detection (refactor, add, document, explain) -- Code injection system with multiple strategies -- User commands: `Coder`, `CoderOpen`, `CoderClose`, `CoderToggle`, `CoderProcess`, `CoderTree`, `CoderTreeView` -- Health check module (`:checkhealth codetyper`) -- Comprehensive documentation and help files -- Telescope integration for file selection (optional) -- **Project tree logging**: Automatic `.coder/tree.log` maintenance - - Updates on file create, save, delete - - Debounced updates (1 second) for performance - - File type icons for visual clarity - - Ignores common build/dependency folders - -### Configuration Options - -- LLM provider selection (Claude/Ollama) -- Window position and width customization -- Custom prompt tag patterns -- Auto gitignore toggle +- Claude API integration +- Ollama API integration +- Automatic `.gitignore` management +- Smart prompt type detection +- Code injection system +- Health check module +- Project tree logging --- @@ -245,7 +247,8 @@ scheduler = { - **Fixed** - Bug fixes - **Security** - Vulnerability fixes -[Unreleased]: https://github.com/cargdev/codetyper.nvim/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/cargdev/codetyper.nvim/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/cargdev/codetyper.nvim/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/cargdev/codetyper.nvim/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/cargdev/codetyper.nvim/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/cargdev/codetyper.nvim/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 36de3f5..e15a567 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,56 @@ -# 🚀 Codetyper.nvim +# Codetyper.nvim **AI-powered coding partner for Neovim** - Write code faster with LLM assistance while staying in control of your logic. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Neovim](https://img.shields.io/badge/Neovim-0.8%2B-green.svg)](https://neovim.io/) -## ✨ Features +## Features -- 📐 **Split View**: Work with your code and prompts side by side -- 💬 **Ask Panel**: Chat interface for questions and explanations -- 🤖 **Agent Mode**: Autonomous coding agent with tool use (read, edit, write, bash) -- 🏷️ **Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts -- ⚡ **Transform Commands**: Transform prompts inline without leaving your file -- 🔌 **Multiple LLM Providers**: Claude, OpenAI, Gemini, Copilot, and Ollama (local) -- 📋 **Event-Driven Scheduler**: Queue-based processing with optimistic execution -- 🎯 **Tree-sitter Scope Resolution**: Smart context extraction for functions/methods -- 🧠 **Intent Detection**: Understands complete, refactor, fix, add, document intents -- 📊 **Confidence Scoring**: Automatic escalation from local to remote LLMs -- 🛡️ **Completion-Aware**: Safe injection that doesn't fight with autocomplete -- 📁 **Auto-Index**: Automatically create coder companion files on file open -- 📜 **Logs Panel**: Real-time visibility into LLM requests and token usage -- 💰 **Cost Tracking**: Persistent LLM cost estimation with session and all-time stats -- 🔒 **Git Integration**: Automatically adds `.coder.*` files to `.gitignore` -- 🌳 **Project Tree Logging**: Maintains a `tree.log` tracking your project structure -- 🧠 **Brain System**: Knowledge graph that learns from your coding patterns +- **Split View**: Work with your code and prompts side by side +- **Ask Panel**: Chat interface for questions and explanations +- **Agent Mode**: Autonomous coding agent with tool use (read, edit, write, bash) +- **Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts +- **Transform Commands**: Transform prompts inline without leaving your file +- **Multiple LLM Providers**: Claude, OpenAI, Gemini, Copilot, and Ollama (local) +- **SEARCH/REPLACE Blocks**: Reliable code editing with fuzzy matching +- **Conflict Resolution**: Git-style diff visualization with interactive resolution +- **Linter Validation**: Auto-check and fix lint errors after code injection +- **Event-Driven Scheduler**: Queue-based processing with optimistic execution +- **Tree-sitter Scope Resolution**: Smart context extraction for functions/methods +- **Intent Detection**: Understands complete, refactor, fix, add, document intents +- **Confidence Scoring**: Automatic escalation from local to remote LLMs +- **Completion-Aware**: Safe injection that doesn't fight with autocomplete +- **Auto-Index**: Automatically create coder companion files on file open +- **Logs Panel**: Real-time visibility into LLM requests and token usage +- **Cost Tracking**: Persistent LLM cost estimation with session and all-time stats +- **Git Integration**: Automatically adds `.coder.*` files to `.gitignore` +- **Project Tree Logging**: Maintains a `tree.log` tracking your project structure +- **Brain System**: Knowledge graph that learns from your coding patterns --- -## 📚 Table of Contents +## Table of Contents -- [Requirements](#-requirements) -- [Installation](#-installation) -- [Quick Start](#-quick-start) -- [Configuration](#-configuration) -- [LLM Providers](#-llm-providers) -- [Commands Reference](#-commands-reference) -- [Usage Guide](#-usage-guide) -- [Logs Panel](#-logs-panel) -- [Cost Tracking](#-cost-tracking) -- [Agent Mode](#-agent-mode) -- [Keymaps](#-keymaps) -- [Health Check](#-health-check) +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [LLM Providers](#llm-providers) +- [Commands Reference](#commands-reference) +- [Keymaps Reference](#keymaps-reference) +- [Usage Guide](#usage-guide) +- [Conflict Resolution](#conflict-resolution) +- [Linter Validation](#linter-validation) +- [Logs Panel](#logs-panel) +- [Cost Tracking](#cost-tracking) +- [Agent Mode](#agent-mode) +- [Health Check](#health-check) +- [Reporting Issues](#reporting-issues) --- -## 📋 Requirements +## Requirements - Neovim >= 0.8.0 - curl (for API calls) @@ -62,7 +68,7 @@ --- -## 📦 Installation +## Installation ### Using [lazy.nvim](https://github.com/folke/lazy.nvim) @@ -70,10 +76,10 @@ { "cargdev/codetyper.nvim", dependencies = { - "nvim-lua/plenary.nvim", -- Required: async utilities - "nvim-treesitter/nvim-treesitter", -- Required: scope detection - "nvim-treesitter/nvim-treesitter-textobjects", -- Optional: text objects - "MunifTanjim/nui.nvim", -- Optional: UI components + "nvim-lua/plenary.nvim", + "nvim-treesitter/nvim-treesitter", + "nvim-treesitter/nvim-treesitter-textobjects", + "MunifTanjim/nui.nvim", }, cmd = { "Coder", "CoderOpen", "CoderToggle", "CoderAgent" }, keys = { @@ -104,7 +110,7 @@ use { --- -## 🚀 Quick Start +## Quick Start **1. Open a file and start Coder:** ```vim @@ -118,11 +124,17 @@ use { using regex, return boolean @/ ``` -**3. The LLM generates code and injects it into `utils.ts` (right panel)** +**3. The LLM generates code and shows a diff for you to review** + +**4. Use conflict resolution keymaps to accept/reject changes:** +- `ct` - Accept AI suggestion (theirs) +- `co` - Keep original code (ours) +- `cb` - Accept both versions +- `cn` - Delete both (none) --- -## ⚙️ Configuration +## Configuration ```lua require("codetyper").setup({ @@ -130,31 +142,26 @@ require("codetyper").setup({ llm = { provider = "claude", -- "claude", "openai", "gemini", "copilot", or "ollama" - -- Claude (Anthropic) settings claude = { api_key = nil, -- Uses ANTHROPIC_API_KEY env var if nil model = "claude-sonnet-4-20250514", }, - -- OpenAI settings openai = { api_key = nil, -- Uses OPENAI_API_KEY env var if nil model = "gpt-4o", endpoint = nil, -- Custom endpoint (Azure, OpenRouter, etc.) }, - -- Google Gemini settings gemini = { api_key = nil, -- Uses GEMINI_API_KEY env var if nil model = "gemini-2.0-flash", }, - -- GitHub Copilot settings (uses copilot.lua/copilot.vim auth) copilot = { model = "gpt-4o", }, - -- Ollama (local) settings ollama = { host = "http://localhost:11434", model = "deepseek-coder:6.7b", @@ -163,7 +170,7 @@ require("codetyper").setup({ -- Window Configuration window = { - width = 25, -- Percentage of screen width (25 = 25%) + width = 25, -- Percentage of screen width position = "left", border = "rounded", }, @@ -176,18 +183,18 @@ require("codetyper").setup({ }, -- Auto Features - auto_gitignore = true, -- Automatically add coder files to .gitignore - auto_open_ask = true, -- Auto-open Ask panel on startup - auto_index = false, -- Auto-create coder companion files on file open + auto_gitignore = true, + auto_open_ask = true, + auto_index = false, -- Event-Driven Scheduler scheduler = { - enabled = true, -- Enable event-driven prompt processing - ollama_scout = true, -- Use Ollama for first attempt (fast local) - escalation_threshold = 0.7, -- Below this confidence, escalate to remote - max_concurrent = 2, -- Max parallel workers - completion_delay_ms = 100, -- Delay injection after completion popup - apply_delay_ms = 5000, -- Wait before applying code (ms), allows review + enabled = true, + ollama_scout = true, + escalation_threshold = 0.7, + max_concurrent = 2, + completion_delay_ms = 100, + apply_delay_ms = 5000, }, }) ``` @@ -202,36 +209,24 @@ require("codetyper").setup({ ### Credentials Management -Instead of storing API keys in your config (which may be committed to git), you can use the credentials system: +Store API keys securely outside of config files: ```vim :CoderAddApiKey ``` -This command interactively prompts for: -1. Provider selection (Claude, OpenAI, Gemini, Copilot, Ollama) -2. API key (for cloud providers) -3. Model name -4. Custom endpoint (for OpenAI-compatible APIs) +Credentials are stored in `~/.local/share/nvim/codetyper/configuration.json`. -Credentials are stored securely in `~/.local/share/nvim/codetyper/configuration.json` (not in your config files). - -**Priority order for credentials:** +**Priority order:** 1. Stored credentials (via `:CoderAddApiKey`) 2. Config file settings 3. Environment variables -**Other credential commands:** -- `:CoderCredentials` - View configured providers -- `:CoderSwitchProvider` - Switch between configured providers -- `:CoderRemoveApiKey` - Remove stored credentials - --- -## 🔌 LLM Providers +## LLM Providers -### Claude (Anthropic) -Best for complex reasoning and code generation. +### Claude ```lua llm = { provider = "claude", @@ -240,19 +235,17 @@ llm = { ``` ### OpenAI -Supports custom endpoints for Azure, OpenRouter, etc. ```lua llm = { provider = "openai", openai = { model = "gpt-4o", - endpoint = "https://api.openai.com/v1/chat/completions", -- optional + endpoint = "https://api.openai.com/v1/chat/completions", }, } ``` ### Google Gemini -Fast and capable. ```lua llm = { provider = "gemini", @@ -261,7 +254,6 @@ llm = { ``` ### GitHub Copilot -Uses your existing Copilot subscription (requires copilot.lua or copilot.vim). ```lua llm = { provider = "copilot", @@ -270,7 +262,6 @@ llm = { ``` ### Ollama (Local) -Run models locally with no API costs. ```lua llm = { provider = "ollama", @@ -283,9 +274,7 @@ llm = { --- -## 📝 Commands Reference - -All commands can be invoked via `:Coder {subcommand}` or their dedicated command aliases. +## Commands Reference ### Core Commands @@ -294,271 +283,111 @@ All commands can be invoked via `:Coder {subcommand}` or their dedicated command | `:Coder open` | `:CoderOpen` | Open the coder split view | | `:Coder close` | `:CoderClose` | Close the coder split view | | `:Coder toggle` | `:CoderToggle` | Toggle the coder split view | -| `:Coder process` | `:CoderProcess` | Process the last prompt in coder file | -| `:Coder status` | - | Show plugin status and configuration | -| `:Coder focus` | - | Switch focus between coder and target windows | -| `:Coder reset` | - | Reset processed prompts to allow re-processing | -| `:Coder gitignore` | - | Force update .gitignore with coder patterns | +| `:Coder process` | `:CoderProcess` | Process the last prompt | +| `:Coder status` | - | Show plugin status | +| `:Coder focus` | - | Switch focus between windows | +| `:Coder reset` | - | Reset processed prompts | -### Ask Panel (Chat Interface) +### Ask Panel | Command | Alias | Description | |---------|-------|-------------| | `:Coder ask` | `:CoderAsk` | Open the Ask panel | | `:Coder ask-toggle` | `:CoderAskToggle` | Toggle the Ask panel | -| `:Coder ask-close` | - | Close the Ask panel | | `:Coder ask-clear` | `:CoderAskClear` | Clear chat history | -### Agent Mode (Autonomous Coding) +### Agent Mode | Command | Alias | Description | |---------|-------|-------------| | `:Coder agent` | `:CoderAgent` | Open the Agent panel | | `:Coder agent-toggle` | `:CoderAgentToggle` | Toggle the Agent panel | -| `:Coder agent-close` | - | Close the Agent panel | -| `:Coder agent-stop` | `:CoderAgentStop` | Stop the running agent | +| `:Coder agent-stop` | `:CoderAgentStop` | Stop running agent | -### Agentic Mode (IDE-like Multi-file Agent) +### Agentic Mode | Command | Alias | Description | |---------|-------|-------------| -| `:Coder agentic-run ` | `:CoderAgenticRun ` | Run an agentic task (multi-file changes) | +| `:Coder agentic-run ` | `:CoderAgenticRun` | Run agentic task | | `:Coder agentic-list` | `:CoderAgenticList` | List available agents | -| `:Coder agentic-init` | `:CoderAgenticInit` | Initialize `.coder/agents/` and `.coder/rules/` | - -### Transform Commands (Inline Tag Processing) - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder transform` | `:CoderTransform` | Transform all `/@ @/` tags in file | -| `:Coder transform-cursor` | `:CoderTransformCursor` | Transform tag at cursor position | -| - | `:CoderTransformVisual` | Transform selected tags (visual mode) | - -### Project & Index Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| - | `:CoderIndex` | Open coder companion for current file | -| `:Coder index-project` | `:CoderIndexProject` | Index the entire project | -| `:Coder index-status` | `:CoderIndexStatus` | Show project index status | - -### Tree & Structure Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder tree` | `:CoderTree` | Refresh `.coder/tree.log` | -| `:Coder tree-view` | `:CoderTreeView` | View `.coder/tree.log` in split | - -### Queue & Scheduler Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder queue-status` | `:CoderQueueStatus` | Show scheduler and queue status | -| `:Coder queue-process` | `:CoderQueueProcess` | Manually trigger queue processing | - -### Processing Mode Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder auto-toggle` | `:CoderAutoToggle` | Toggle automatic/manual prompt processing | -| `:Coder auto-set ` | `:CoderAutoSet ` | Set processing mode (`auto`/`manual`) | - -### Memory & Learning Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder memories` | `:CoderMemories` | Show learned memories | -| `:Coder forget [pattern]` | `:CoderForget [pattern]` | Clear memories (optionally matching pattern) | - -### Brain Commands (Knowledge Graph) - -| Command | Alias | Description | -|---------|-------|-------------| -| - | `:CoderBrain [action]` | Brain management (`stats`/`commit`/`flush`/`prune`) | -| - | `:CoderFeedback ` | Give feedback to brain (`good`/`bad`/`stats`) | - -### LLM Statistics & Feedback - -| Command | Description | -|---------|-------------| -| `:Coder llm-stats` | Show LLM provider accuracy statistics | -| `:Coder llm-feedback-good` | Report positive feedback on last response | -| `:Coder llm-feedback-bad` | Report negative feedback on last response | -| `:Coder llm-reset-stats` | Reset LLM accuracy statistics | - -### Cost Tracking - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder cost` | `:CoderCost` | Show LLM cost estimation window | -| `:Coder cost-clear` | - | Clear session cost tracking | - -### Credentials Management - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder add-api-key` | `:CoderAddApiKey` | Add or update LLM provider API key | -| `:Coder remove-api-key` | `:CoderRemoveApiKey` | Remove LLM provider credentials | -| `:Coder credentials` | `:CoderCredentials` | Show credentials status | -| `:Coder switch-provider` | `:CoderSwitchProvider` | Switch active LLM provider | - -### UI Commands - -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder type-toggle` | `:CoderType` | Show Ask/Agent mode switcher | -| `:Coder logs-toggle` | `:CoderLogs` | Toggle logs panel | - ---- - -## 📖 Usage Guide - -### Tag-Based Prompts - -Write prompts in your coder file using `/@` and `@/` tags: - -```typescript -/@ Create a Button component with the following props: -- variant: 'primary' | 'secondary' | 'danger' -- size: 'sm' | 'md' | 'lg' -- disabled: boolean -Use Tailwind CSS for styling @/ -``` - -When you close the tag with `@/`, the prompt is automatically processed. +| `:Coder agentic-init` | `:CoderAgenticInit` | Initialize .coder/agents/ | ### Transform Commands -Transform prompts inline without the split view: +| Command | Alias | Description | +|---------|-------|-------------| +| `:Coder transform` | `:CoderTransform` | Transform all tags in file | +| `:Coder transform-cursor` | `:CoderTransformCursor` | Transform tag at cursor | +| - | `:CoderTransformVisual` | Transform selected tags | -```typescript -// In your source file: -/@ Add input validation for email and password @/ +### Conflict Resolution -// Run :CoderTransformCursor to transform the prompt at cursor -``` +| Command | Description | +|---------|-------------| +| `:CoderConflictToggle` | Toggle conflict mode | +| `:CoderConflictMenu` | Show resolution menu | +| `:CoderConflictNext` | Go to next conflict | +| `:CoderConflictPrev` | Go to previous conflict | +| `:CoderConflictStatus` | Show conflict status | +| `:CoderConflictResolveAll [keep]` | Resolve all (ours/theirs/both/none) | +| `:CoderConflictAcceptCurrent` | Accept original code | +| `:CoderConflictAcceptIncoming` | Accept AI suggestion | +| `:CoderConflictAcceptBoth` | Accept both versions | +| `:CoderConflictAcceptNone` | Delete both | +| `:CoderConflictAutoMenu` | Toggle auto-show menu | -### Prompt Types +### Linter Validation -The plugin auto-detects prompt type: +| Command | Description | +|---------|-------------| +| `:CoderLintCheck` | Check buffer for lint errors | +| `:CoderLintFix` | Request AI to fix lint errors | +| `:CoderLintQuickfix` | Show errors in quickfix | +| `:CoderLintToggleAuto` | Toggle auto lint checking | -| Keywords | Type | Behavior | -|----------|------|----------| -| `complete`, `finish`, `implement`, `todo` | Complete | Completes function body (replaces scope) | -| `refactor`, `rewrite`, `simplify` | Refactor | Replaces code | -| `fix`, `debug`, `bug`, `error` | Fix | Fixes bugs (replaces scope) | -| `add`, `create`, `generate` | Add | Inserts new code | -| `document`, `comment`, `jsdoc` | Document | Adds documentation | -| `optimize`, `performance`, `faster` | Optimize | Optimizes code (replaces scope) | -| `explain`, `what`, `how` | Explain | Shows explanation only | +### Queue & Scheduler -### Function Completion +| Command | Alias | Description | +|---------|-------|-------------| +| `:Coder queue-status` | `:CoderQueueStatus` | Show scheduler status | +| `:Coder queue-process` | `:CoderQueueProcess` | Trigger queue processing | -When you write a prompt **inside** a function body, the plugin uses Tree-sitter to detect the enclosing scope and automatically switches to "complete" mode: +### Processing Mode -```typescript -function getUserById(id: number): User | null { - /@ return the user from the database by id, handle not found case @/ -} -``` +| Command | Alias | Description | +|---------|-------|-------------| +| `:Coder auto-toggle` | `:CoderAutoToggle` | Toggle auto/manual mode | +| `:Coder auto-set ` | `:CoderAutoSet` | Set mode (auto/manual) | -The LLM will complete the function body while keeping the exact same signature. The entire function scope is replaced with the completed version. +### Brain & Memory + +| Command | Description | +|---------|-------------| +| `:CoderMemories` | Show learned memories | +| `:CoderForget [pattern]` | Clear memories | +| `:CoderBrain [action]` | Brain management (stats/commit/flush/prune) | +| `:CoderFeedback ` | Give feedback (good/bad/stats) | + +### Cost & Credentials + +| Command | Description | +|---------|-------------| +| `:CoderCost` | Show cost estimation window | +| `:CoderAddApiKey` | Add/update API key | +| `:CoderRemoveApiKey` | Remove credentials | +| `:CoderCredentials` | Show credentials status | +| `:CoderSwitchProvider` | Switch LLM provider | + +### UI Commands + +| Command | Description | +|---------|-------------| +| `:CoderLogs` | Toggle logs panel | +| `:CoderType` | Show Ask/Agent switcher | --- -## 📊 Logs Panel - -The logs panel provides real-time visibility into LLM operations: - -### Features - -- **Generation Logs**: Shows all LLM requests, responses, and token usage -- **Queue Display**: Shows pending and processing prompts -- **Full Response View**: Complete LLM responses are logged for debugging -- **Auto-cleanup**: Logs panel and queue windows automatically close when exiting Neovim - -### Opening the Logs Panel - -```vim -:CoderLogs -``` - -The logs panel opens automatically when processing prompts with the scheduler enabled. - -### Keymaps - -| Key | Description | -|-----|-------------| -| `q` | Close logs panel | -| `` | Close logs panel | - ---- - -## 💰 Cost Tracking - -Track your LLM API costs across sessions with the Cost Estimation window. - -### Features - -- **Session Tracking**: Monitor current session token usage and costs -- **All-Time Tracking**: Persistent cost history stored per-project in `.coder/cost_history.json` -- **Model Breakdown**: See costs by individual model -- **Pricing Database**: Built-in pricing for 50+ models (GPT, Claude, Gemini, O-series, etc.) - -### Opening the Cost Window - -```vim -:CoderCost -``` - -### Cost Window Keymaps - -| Key | Description | -|-----|-------------| -| `q` / `` | Close window | -| `r` | Refresh display | -| `c` | Clear session costs | -| `C` | Clear all history | - -### Supported Models - -The cost tracker includes pricing for: -- **OpenAI**: GPT-4, GPT-4o, GPT-4o-mini, O1, O3, O4-mini, and more -- **Anthropic**: Claude 3 Opus, Sonnet, Haiku, Claude 3.5 Sonnet/Haiku -- **Local**: Ollama models (free, but usage tracked) -- **Copilot**: Usage tracked (included in subscription) - ---- - -## 🤖 Agent Mode - -The Agent mode provides an autonomous coding assistant with tool access: - -### Available Tools - -- **read_file**: Read file contents -- **edit_file**: Edit files with find/replace -- **write_file**: Create or overwrite files -- **bash**: Execute shell commands - -### Using Agent Mode - -1. Open the agent panel: `:CoderAgent` or `ca` -2. Describe what you want to accomplish -3. The agent will use tools to complete the task -4. Review changes before they're applied - -### Agent Keymaps - -| Key | Description | -|-----|-------------| -| `` | Submit message | -| `Ctrl+c` | Stop agent execution | -| `q` | Close agent panel | - ---- - -## ⌨️ Keymaps +## Keymaps Reference ### Default Keymaps (auto-configured) @@ -568,7 +397,36 @@ The Agent mode provides an autonomous coding assistant with tool access: | `ctt` | Visual | Transform selected tags | | `ctT` | Normal | Transform all tags in file | | `ca` | Normal | Toggle Agent panel | -| `ci` | Normal | Open coder companion (index) | +| `ci` | Normal | Open coder companion | + +### Conflict Resolution Keymaps (buffer-local when conflicts exist) + +| Key | Description | +|-----|-------------| +| `co` | Accept CURRENT (original) code | +| `ct` | Accept INCOMING (AI suggestion) | +| `cb` | Accept BOTH versions | +| `cn` | Delete conflict (accept NONE) | +| `cm` | Show conflict resolution menu | +| `]x` | Go to next conflict | +| `[x` | Go to previous conflict | +| `` | Show menu when on conflict | + +### Conflict Menu Keymaps (in floating menu) + +| Key | Description | +|-----|-------------| +| `1` | Accept current (original) | +| `2` | Accept incoming (AI) | +| `3` | Accept both | +| `4` | Accept none | +| `co` | Accept current | +| `ct` | Accept incoming | +| `cb` | Accept both | +| `cn` | Accept none | +| `]x` | Go to next conflict | +| `[x` | Go to previous conflict | +| `q` / `` | Close menu | ### Ask Panel Keymaps @@ -581,6 +439,29 @@ The Agent mode provides an autonomous coding assistant with tool access: | `q` | Close panel | | `Y` | Copy last response | +### Agent Panel Keymaps + +| Key | Description | +|-----|-------------| +| `` | Submit message | +| `Ctrl+c` | Stop agent execution | +| `q` | Close agent panel | + +### Logs Panel Keymaps + +| Key | Description | +|-----|-------------| +| `q` / `` | Close logs panel | + +### Cost Window Keymaps + +| Key | Description | +|-----|-------------| +| `q` / `` | Close window | +| `r` | Refresh display | +| `c` | Clear session costs | +| `C` | Clear all history | + ### Suggested Additional Keymaps ```lua @@ -591,63 +472,288 @@ map("n", "cc", "Coder close", { desc = "Coder: Close" }) map("n", "ct", "Coder toggle", { desc = "Coder: Toggle" }) map("n", "cp", "Coder process", { desc = "Coder: Process" }) map("n", "cs", "Coder status", { desc = "Coder: Status" }) +map("n", "cl", "CoderLogs", { desc = "Coder: Logs" }) +map("n", "cm", "CoderConflictMenu", { desc = "Coder: Conflict Menu" }) ``` --- -## 🏥 Health Check +## Usage Guide -Verify your setup: +### Tag-Based Prompts + +Write prompts using `/@` and `@/` tags: + +```typescript +/@ Create a Button component with: +- variant: 'primary' | 'secondary' | 'danger' +- size: 'sm' | 'md' | 'lg' +Use Tailwind CSS for styling @/ +``` + +### Prompt Types + +| Keywords | Type | Behavior | +|----------|------|----------| +| `complete`, `finish`, `implement` | Complete | Replaces scope | +| `refactor`, `rewrite`, `simplify` | Refactor | Replaces code | +| `fix`, `debug`, `bug`, `error` | Fix | Fixes bugs | +| `add`, `create`, `generate` | Add | Inserts new code | +| `document`, `comment`, `jsdoc` | Document | Adds docs | +| `explain`, `what`, `how` | Explain | Shows explanation | + +### Function Completion + +When you write a prompt inside a function, the plugin detects the enclosing scope: + +```typescript +function getUserById(id: number): User | null { + /@ return the user from the database by id @/ +} +``` + +--- + +## Conflict Resolution + +When code is generated, it's shown as a git-style conflict for you to review: + +``` +<<<<<<< CURRENT +// Original code here +======= +// AI-generated code here +>>>>>>> INCOMING +``` + +### Visual Indicators + +- **Green background**: Original (CURRENT) code +- **Blue background**: AI-generated (INCOMING) code +- **Virtual text hints**: Shows available keymaps + +### Resolution Options + +1. **Accept Current (`co`)**: Keep your original code +2. **Accept Incoming (`ct`)**: Use the AI suggestion +3. **Accept Both (`cb`)**: Keep both versions +4. **Accept None (`cn`)**: Delete the entire conflict + +### Auto-Show Menu + +When code is injected, a floating menu automatically appears. After resolving a conflict, the menu shows again for the next conflict. + +Toggle auto-show: `:CoderConflictAutoMenu` + +--- + +## Linter Validation + +After accepting AI suggestions (`ct` or `cb`), the plugin: + +1. **Saves the file** automatically +2. **Checks LSP diagnostics** for errors/warnings +3. **Offers to fix** lint errors with AI + +### Configuration + +```lua +-- In conflict.lua config +lint_after_accept = true, -- Check linter after accepting +auto_fix_lint_errors = true, -- Auto-queue fix without prompting +``` + +### Manual Commands + +- `:CoderLintCheck` - Check current buffer +- `:CoderLintFix` - Queue AI fix for errors +- `:CoderLintQuickfix` - Show in quickfix list + +--- + +## Logs Panel + +Real-time visibility into LLM operations: + +```vim +:CoderLogs +``` + +Shows: +- Generation requests and responses +- Token usage +- Queue status +- Errors and warnings + +--- + +## Cost Tracking + +Track LLM API costs across sessions: + +```vim +:CoderCost +``` + +Features: +- Session and all-time statistics +- Per-model breakdown +- Pricing for 50+ models +- Persistent history in `.coder/cost_history.json` + +--- + +## Agent Mode + +Autonomous coding assistant with tool access: + +### Available Tools + +- **read_file**: Read file contents +- **edit_file**: Edit files with find/replace +- **write_file**: Create or overwrite files +- **bash**: Execute shell commands + +### Using Agent Mode + +1. Open: `:CoderAgent` or `ca` +2. Describe your task +3. Agent uses tools autonomously +4. Review changes in conflict mode + +--- + +## Health Check ```vim :checkhealth codetyper ``` -This checks: -- Neovim version -- curl availability -- LLM configuration -- API key status -- Telescope availability (optional) - --- -## 📁 File Structure +## File Structure ``` your-project/ -├── .coder/ # Auto-created, gitignored -│ └── tree.log # Project structure log +├── .coder/ +│ ├── tree.log +│ ├── cost_history.json +│ ├── brain/ +│ ├── agents/ +│ └── rules/ ├── src/ -│ ├── index.ts # Your source file -│ ├── index.coder.ts # Coder file (gitignored) -└── .gitignore # Auto-updated with coder patterns +│ ├── index.ts +│ └── index.coder.ts +└── .gitignore ``` --- -## 🤝 Contributing +## Reporting Issues -Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. +Found a bug or have a feature request? Please create an issue on GitHub. + +### Before Creating an Issue + +1. **Search existing issues** to avoid duplicates +2. **Update to the latest version** and check if the issue persists +3. **Run health check**: `:checkhealth codetyper` + +### Bug Reports + +When reporting a bug, please include: + +```markdown +**Description** +A clear description of what the bug is. + +**Steps to Reproduce** +1. Open file '...' +2. Run command '...' +3. See error + +**Expected Behavior** +What you expected to happen. + +**Actual Behavior** +What actually happened. + +**Environment** +- Neovim version: (output of `nvim --version`) +- Plugin version: (commit hash or tag) +- OS: (e.g., macOS 14.0, Ubuntu 22.04) +- LLM Provider: (e.g., Claude, OpenAI, Ollama) + +**Error Messages** +Paste any error messages from `:messages` + +**Minimal Config** +If possible, provide a minimal config to reproduce: +```lua +-- minimal.lua +require("codetyper").setup({ + llm = { provider = "..." }, +}) +``` +``` + +### Feature Requests + +For feature requests, please describe: + +- **Use case**: What problem does this solve? +- **Proposed solution**: How should it work? +- **Alternatives**: Other solutions you've considered + +### Debug Information + +To gather debug information: + +```vim +" Check plugin status +:Coder status + +" View logs +:CoderLogs + +" Check health +:checkhealth codetyper + +" View recent messages +:messages +``` + +### Issue Labels + +- `bug` - Something isn't working +- `enhancement` - New feature request +- `documentation` - Documentation improvements +- `question` - General questions +- `help wanted` - Issues that need community help --- -## 📄 License +## Contributing -MIT License - see [LICENSE](LICENSE) for details. +Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). --- -## 👨‍💻 Author +## License + +MIT License - see [LICENSE](LICENSE). + +--- + +## Author **cargdev** - Website: [cargdev.io](https://cargdev.io) -- Blog: [blog.cargdev.io](https://blog.cargdev.io) - Email: carlos.gutierrez@carg.dev ---

- Made with ❤️ for the Neovim community + Made with care for the Neovim community

diff --git a/doc/codetyper.txt b/doc/codetyper.txt index d87d7b5..9085eff 100644 --- a/doc/codetyper.txt +++ b/doc/codetyper.txt @@ -120,7 +120,7 @@ Default configuration: >lua 5. LLM PROVIDERS *codetyper-providers* *codetyper-claude* -Claude (Anthropic)~ +Claude~ Best for complex reasoning and code generation. >lua llm = { diff --git a/llms.txt b/llms.txt index a3ec734..d2bda73 100644 --- a/llms.txt +++ b/llms.txt @@ -14,68 +14,70 @@ Instead of having an AI generate entire files, Codetyper lets developers maintai 2. A companion "coder file" is created (`index.coder.ts`) 3. Developer writes prompts using special tags: `/@ prompt @/` 4. When the closing tag is typed, the LLM generates code -5. Generated code is injected into the target file +5. Generated code is shown as a conflict for review +6. Developer accepts/rejects changes using keymaps ## Plugin Architecture ``` lua/codetyper/ -├── init.lua # Main entry, setup function, module initialization -├── config.lua # Configuration management, defaults, validation -├── types.lua # Lua type definitions for LSP/documentation -├── utils.lua # Utility functions (file ops, notifications) -├── commands.lua # Vim command definitions (:Coder, :CoderOpen, etc.) -├── window.lua # Split window management (open, close, toggle) -├── parser.lua # Parses /@ @/ tags from buffer content -├── gitignore.lua # Manages .gitignore entries for coder files -├── autocmds.lua # Autocommands for tag detection, filetype, auto-index -├── inject.lua # Code injection strategies -├── health.lua # Health check for :checkhealth -├── tree.lua # Project tree logging (.coder/tree.log) -├── logs_panel.lua # Standalone logs panel UI -├── cost.lua # LLM cost tracking with persistent history -├── credentials.lua # Secure credential storage (API keys, models) +├── init.lua # Main entry, setup function +├── config.lua # Configuration management +├── types.lua # Lua type definitions +├── utils.lua # Utility functions +├── commands.lua # Vim command definitions +├── window.lua # Split window management +├── parser.lua # Parses /@ @/ tags +├── gitignore.lua # Manages .gitignore entries +├── autocmds.lua # Autocommands for tag detection +├── inject.lua # Code injection strategies +├── health.lua # Health check for :checkhealth +├── tree.lua # Project tree logging +├── logs_panel.lua # Standalone logs panel UI +├── cost.lua # LLM cost tracking +├── credentials.lua # Secure credential storage ├── llm/ -│ ├── init.lua # LLM interface, provider selection -│ ├── claude.lua # Claude API client (Anthropic) -│ ├── openai.lua # OpenAI API client (with custom endpoint support) -│ ├── gemini.lua # Google Gemini API client -│ ├── copilot.lua # GitHub Copilot client (uses OAuth from copilot.lua/vim) -│ └── ollama.lua # Ollama API client (local LLMs) +│ ├── init.lua # LLM interface, provider selection +│ ├── claude.lua # Claude API client +│ ├── openai.lua # OpenAI API client +│ ├── gemini.lua # Google Gemini API client +│ ├── copilot.lua # GitHub Copilot client +│ └── ollama.lua # Ollama API client (local) ├── agent/ -│ ├── init.lua # Agent system entry point -│ ├── ui.lua # Agent panel UI -│ ├── logs.lua # Logging system with listeners -│ ├── tools.lua # Tool definitions (read_file, edit_file, write_file, bash) -│ ├── executor.lua # Tool execution logic -│ ├── parser.lua # Parse tool calls from LLM responses -│ ├── queue.lua # Event queue with priority heap -│ ├── patch.lua # Patch candidates with staleness detection -│ ├── confidence.lua # Response confidence scoring heuristics -│ ├── worker.lua # Async LLM worker wrapper -│ ├── scheduler.lua # Event scheduler with completion-awareness -│ ├── scope.lua # Tree-sitter scope resolution -│ └── intent.lua # Intent detection from prompts +│ ├── init.lua # Agent system entry point +│ ├── ui.lua # Agent panel UI +│ ├── logs.lua # Logging system +│ ├── tools.lua # Tool definitions (read, edit, write, bash) +│ ├── executor.lua # Tool execution logic +│ ├── parser.lua # Parse tool calls from responses +│ ├── queue.lua # Event queue with priority heap +│ ├── patch.lua # Patch candidates with staleness detection +│ ├── confidence.lua # Response confidence scoring +│ ├── worker.lua # Async LLM worker +│ ├── scheduler.lua # Event scheduler +│ ├── scope.lua # Tree-sitter scope resolution +│ ├── intent.lua # Intent detection from prompts +│ ├── conflict.lua # Git-style conflict resolution +│ ├── linter.lua # LSP diagnostics validation +│ └── search_replace.lua # SEARCH/REPLACE block parsing ├── ask/ -│ ├── init.lua # Ask panel entry point -│ └── ui.lua # Ask panel UI (chat interface) +│ ├── init.lua # Ask panel entry point +│ └── ui.lua # Ask panel UI (chat interface) └── prompts/ - ├── init.lua # System prompts for code generation - └── agent.lua # Agent-specific prompts and tool instructions + ├── init.lua # System prompts for code generation + └── agent.lua # Agent-specific prompts ``` ## .coder/ Folder -The plugin automatically creates and maintains a `.coder/` folder in your project: - ``` .coder/ -├── tree.log # Project structure, auto-updated on file changes -├── cost_history.json # LLM cost tracking history (persistent) +├── tree.log # Project structure, auto-updated +├── cost_history.json # LLM cost tracking history ├── brain/ # Knowledge graph storage -│ ├── nodes/ # Learning nodes by type -│ ├── indices/ # Search indices -│ └── deltas/ # Version history +│ ├── nodes/ +│ ├── indices/ +│ └── deltas/ ├── agents/ # Custom agent definitions └── rules/ # Project-specific rules ``` @@ -95,108 +97,122 @@ llm = { } ``` -### 2. Agent Mode +### 2. Conflict Resolution System + +Git-style diff visualization for code review: + +``` +<<<<<<< CURRENT +// Original code +======= +// AI-generated code +>>>>>>> INCOMING +``` + +**Keymaps (buffer-local when conflicts exist):** +| Key | Description | +|-----|-------------| +| `co` | Accept CURRENT (original) code | +| `ct` | Accept INCOMING (AI suggestion) | +| `cb` | Accept BOTH versions | +| `cn` | Delete conflict (accept NONE) | +| `cm` | Show conflict resolution menu | +| `]x` | Go to next conflict | +| `[x` | Go to previous conflict | +| `` | Show menu when on conflict | + +**Menu keymaps:** +| Key | Description | +|-----|-------------| +| `1` | Accept current | +| `2` | Accept incoming | +| `3` | Accept both | +| `4` | Accept none | +| `q`/`` | Close menu | + +**Configuration:** +```lua +-- In conflict.lua +config = { + lint_after_accept = true, -- Check linter after accepting + auto_fix_lint_errors = true, -- Auto-queue fix + auto_show_menu = true, -- Show menu after injection + auto_show_next_menu = true, -- Show menu for next conflict +} +``` + +### 3. Linter Validation + +Auto-check and fix lint errors after code injection: + +```lua +-- In linter.lua +config = { + auto_save = true, -- Save file after injection + diagnostic_delay_ms = 500, -- Wait for LSP + min_severity = vim.diagnostic.severity.WARN, + auto_offer_fix = true, -- Offer to fix errors +} +``` + +**Commands:** +- `:CoderLintCheck` - Check buffer for lint errors +- `:CoderLintFix` - Request AI to fix lint errors +- `:CoderLintQuickfix` - Show errors in quickfix +- `:CoderLintToggleAuto` - Toggle auto lint checking + +### 4. SEARCH/REPLACE Block System + +Reliable code editing with fuzzy matching: + +``` +<<<<<<< SEARCH +function oldCode() { + // original +} +======= +function newCode() { + // replacement +} +>>>>>>> REPLACE +``` + +**Configuration:** +```lua +-- In search_replace.lua +config = { + fuzzy_threshold = 0.8, -- Minimum similarity + normalize_whitespace = true, -- Ignore whitespace differences + context_lines = 3, -- Lines for context matching +} +``` + +### 5. Agent Mode Autonomous coding assistant with tool access: +**Available Tools:** - `read_file` - Read file contents - `edit_file` - Edit files with find/replace - `write_file` - Create or overwrite files - `bash` - Execute shell commands -### 3. Transform Commands - -Transform `/@ @/` tags inline without split view: - -- `:CoderTransform` - Transform all tags in file -- `:CoderTransformCursor` - Transform tag at cursor -- `:CoderTransformVisual` - Transform selected tags - -### 4. Auto-Index - -Automatically create coder companion files when opening source files: - -```lua -auto_index = true -- disabled by default -``` - -### 5. Logs Panel - -Real-time visibility into LLM operations with token usage tracking. - -### 6. Cost Tracking - -Track LLM API costs across sessions: - -- **Session tracking**: Monitor current session costs in real-time -- **All-time tracking**: Persistent history in `.coder/cost_history.json` -- **Per-model breakdown**: See costs by individual model -- **50+ models**: Built-in pricing for GPT, Claude, O-series, Gemini - -Cost window keymaps: -- `q`/`` - Close window -- `r` - Refresh display -- `c` - Clear session costs -- `C` - Clear all history - -### 7. Automatic Ollama Fallback - -When API rate limits are hit (e.g., Copilot free tier), the plugin: -1. Detects the rate limit error -2. Checks if local Ollama is available -3. Automatically switches provider to Ollama -4. Notifies user of the provider change - -### 8. Credentials Management - -Store API keys securely outside of config files: - -```vim -:CoderAddApiKey -``` - -**Features:** -- Interactive prompts for provider, API key, model, endpoint -- Stored in `~/.local/share/nvim/codetyper/configuration.json` -- Supports all providers: Claude, OpenAI, Gemini, Copilot, Ollama -- Switch providers at runtime with `:CoderSwitchProvider` - -**Credential priority:** -1. Stored credentials (via `:CoderAddApiKey`) -2. Config file settings (`require("codetyper").setup({...})`) -3. Environment variables (`OPENAI_API_KEY`, etc.) - -### 9. Event-Driven Scheduler - -Prompts are treated as events, not commands: +### 6. Event-Driven Scheduler ``` -User types /@...@/ → Event queued → Scheduler dispatches → Worker processes → Patch created → Safe injection +User types /@...@/ → Event queued → Scheduler dispatches → Worker processes → Patch created → Conflict shown ``` **Key concepts:** +- **PromptEvent**: Captures buffer state at prompt time +- **Optimistic Execution**: Ollama as fast scout +- **Confidence Scoring**: 5 heuristics +- **Staleness Detection**: Discard if buffer changed +- **Completion Safety**: Defer while autocomplete visible -- **PromptEvent**: Captures buffer state (changedtick, content hash) at prompt time -- **Optimistic Execution**: Ollama as fast scout, escalate to remote LLMs if confidence low -- **Confidence Scoring**: 5 heuristics (length, uncertainty, syntax, repetition, truncation) -- **Staleness Detection**: Discard patches if buffer changed during generation -- **Completion Safety**: Defer injection while autocomplete popup visible +### 7. Tree-sitter Scope Resolution -**Configuration:** - -```lua -scheduler = { - enabled = true, -- Enable event-driven mode - ollama_scout = true, -- Use Ollama first - escalation_threshold = 0.7, -- Below this → escalate - max_concurrent = 2, -- Parallel workers - completion_delay_ms = 100, -- Wait after popup closes -} -``` - -### 10. Tree-sitter Scope Resolution - -Prompts automatically resolve to their enclosing function/method/class: +Prompts automatically resolve to enclosing scope: ```lua function foo() @@ -206,12 +222,7 @@ end **Scope types:** `function`, `method`, `class`, `block`, `file` -For replacement intents (complete, refactor, fix), the entire scope is extracted -and sent to the LLM, then replaced with the transformed version. - -### 11. Intent Detection - -The system parses prompts to detect user intent: +### 8. Intent Detection | Intent | Keywords | Action | |--------|----------|--------| @@ -221,157 +232,174 @@ The system parses prompts to detect user intent: | add | add, create, insert, new | insert | | document | document, comment, jsdoc | replace | | test | test, spec, unit test | append | -| optimize | optimize, performance, faster | replace | -| explain | explain, what, how, why | none | +| optimize | optimize, performance | replace | +| explain | explain, what, how | none | -### 12. Tag Precedence +### 9. Cost Tracking -Multiple tags in the same scope follow "first tag wins" rule: -- Earlier (by line number) unresolved tag processes first -- Later tags in same scope are skipped with warning -- Different scopes process independently +Track LLM API costs: +- Session costs tracked in real-time +- All-time costs in `.coder/cost_history.json` +- Pricing for 50+ models -## Commands +### 10. Credentials Management -All commands can be invoked via `:Coder {subcommand}` or dedicated aliases. +```vim +:CoderAddApiKey +``` + +Stored in `~/.local/share/nvim/codetyper/configuration.json` + +**Priority:** stored credentials > config > environment variables + +## Commands Reference ### Core Commands | Command | Alias | Description | |---------|-------|-------------| -| `:Coder open` | `:CoderOpen` | Open coder split view | -| `:Coder close` | `:CoderClose` | Close coder split view | -| `:Coder toggle` | `:CoderToggle` | Toggle coder split view | -| `:Coder process` | `:CoderProcess` | Process last prompt in coder file | -| `:Coder status` | - | Show plugin status and configuration | -| `:Coder focus` | - | Switch focus between coder/target windows | +| `:Coder open` | `:CoderOpen` | Open coder split | +| `:Coder close` | `:CoderClose` | Close coder split | +| `:Coder toggle` | `:CoderToggle` | Toggle coder split | +| `:Coder process` | `:CoderProcess` | Process last prompt | +| `:Coder status` | - | Show status | +| `:Coder focus` | - | Switch focus | | `:Coder reset` | - | Reset processed prompts | -| `:Coder gitignore` | - | Force update .gitignore | -### Ask Panel (Chat Interface) +### Ask Panel | Command | Alias | Description | |---------|-------|-------------| | `:Coder ask` | `:CoderAsk` | Open Ask panel | | `:Coder ask-toggle` | `:CoderAskToggle` | Toggle Ask panel | -| `:Coder ask-close` | - | Close Ask panel | -| `:Coder ask-clear` | `:CoderAskClear` | Clear chat history | +| `:Coder ask-clear` | `:CoderAskClear` | Clear chat | -### Agent Mode (Autonomous Coding) +### Agent Mode | Command | Alias | Description | |---------|-------|-------------| | `:Coder agent` | `:CoderAgent` | Open Agent panel | | `:Coder agent-toggle` | `:CoderAgentToggle` | Toggle Agent panel | -| `:Coder agent-close` | - | Close Agent panel | -| `:Coder agent-stop` | `:CoderAgentStop` | Stop running agent | +| `:Coder agent-stop` | `:CoderAgentStop` | Stop agent | -### Agentic Mode (IDE-like Multi-file Agent) +### Transform Commands | Command | Alias | Description | |---------|-------|-------------| -| `:Coder agentic-run ` | `:CoderAgenticRun ` | Run agentic task | -| `:Coder agentic-list` | `:CoderAgenticList` | List available agents | -| `:Coder agentic-init` | `:CoderAgenticInit` | Initialize .coder/agents/ and .coder/rules/ | +| `:Coder transform` | `:CoderTransform` | Transform all tags | +| `:Coder transform-cursor` | `:CoderTransformCursor` | Transform at cursor | +| - | `:CoderTransformVisual` | Transform selected | -### Transform Commands (Inline Tag Processing) -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder transform` | `:CoderTransform` | Transform all /@ @/ tags in file | -| `:Coder transform-cursor` | `:CoderTransformCursor` | Transform tag at cursor | -| - | `:CoderTransformVisual` | Transform selected tags (visual mode) | - -### Project & Index Commands -| Command | Alias | Description | -|---------|-------|-------------| -| - | `:CoderIndex` | Open coder companion for current file | -| `:Coder index-project` | `:CoderIndexProject` | Index entire project | -| `:Coder index-status` | `:CoderIndexStatus` | Show project index status | - -### Tree & Structure Commands -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder tree` | `:CoderTree` | Refresh .coder/tree.log | -| `:Coder tree-view` | `:CoderTreeView` | View .coder/tree.log | - -### Queue & Scheduler Commands -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder queue-status` | `:CoderQueueStatus` | Show scheduler/queue status | -| `:Coder queue-process` | `:CoderQueueProcess` | Manually trigger queue processing | - -### Processing Mode Commands -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder auto-toggle` | `:CoderAutoToggle` | Toggle automatic/manual processing | -| `:Coder auto-set ` | `:CoderAutoSet ` | Set mode (auto/manual) | - -### Memory & Learning Commands -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder memories` | `:CoderMemories` | Show learned memories | -| `:Coder forget [pattern]` | `:CoderForget [pattern]` | Clear memories | - -### Brain Commands (Knowledge Graph) -| Command | Alias | Description | -|---------|-------|-------------| -| - | `:CoderBrain [action]` | Brain management (stats/commit/flush/prune) | -| - | `:CoderFeedback ` | Give feedback (good/bad/stats) | - -### LLM Statistics & Feedback +### Conflict Resolution | Command | Description | |---------|-------------| -| `:Coder llm-stats` | Show LLM provider accuracy stats | -| `:Coder llm-feedback-good` | Report positive feedback | -| `:Coder llm-feedback-bad` | Report negative feedback | -| `:Coder llm-reset-stats` | Reset LLM accuracy stats | +| `:CoderConflictToggle` | Toggle conflict mode | +| `:CoderConflictMenu` | Show resolution menu | +| `:CoderConflictNext` | Go to next conflict | +| `:CoderConflictPrev` | Go to previous conflict | +| `:CoderConflictStatus` | Show conflict status | +| `:CoderConflictResolveAll [keep]` | Resolve all | +| `:CoderConflictAcceptCurrent` | Accept original | +| `:CoderConflictAcceptIncoming` | Accept AI | +| `:CoderConflictAcceptBoth` | Accept both | +| `:CoderConflictAcceptNone` | Delete both | +| `:CoderConflictAutoMenu` | Toggle auto-show menu | -### Cost Tracking +### Linter Validation +| Command | Description | +|---------|-------------| +| `:CoderLintCheck` | Check buffer | +| `:CoderLintFix` | AI fix errors | +| `:CoderLintQuickfix` | Show in quickfix | +| `:CoderLintToggleAuto` | Toggle auto lint | + +### Queue & Scheduler | Command | Alias | Description | |---------|-------|-------------| -| `:Coder cost` | `:CoderCost` | Show LLM cost estimation window | -| `:Coder cost-clear` | - | Clear session cost tracking | +| `:Coder queue-status` | `:CoderQueueStatus` | Show status | +| `:Coder queue-process` | `:CoderQueueProcess` | Trigger processing | -### Credentials Management +### Processing Mode | Command | Alias | Description | |---------|-------|-------------| -| `:Coder add-api-key` | `:CoderAddApiKey` | Add/update LLM provider credentials | -| `:Coder remove-api-key` | `:CoderRemoveApiKey` | Remove provider credentials | -| `:Coder credentials` | `:CoderCredentials` | Show credentials status | -| `:Coder switch-provider` | `:CoderSwitchProvider` | Switch active provider | +| `:Coder auto-toggle` | `:CoderAutoToggle` | Toggle auto/manual | +| `:Coder auto-set ` | `:CoderAutoSet` | Set mode | + +### Brain & Memory +| Command | Description | +|---------|-------------| +| `:CoderMemories` | Show memories | +| `:CoderForget [pattern]` | Clear memories | +| `:CoderBrain [action]` | Brain management | +| `:CoderFeedback ` | Give feedback | + +### Cost & Credentials +| Command | Description | +|---------|-------------| +| `:CoderCost` | Show cost window | +| `:CoderAddApiKey` | Add/update API key | +| `:CoderRemoveApiKey` | Remove credentials | +| `:CoderCredentials` | Show credentials | +| `:CoderSwitchProvider` | Switch provider | ### UI Commands -| Command | Alias | Description | -|---------|-------|-------------| -| `:Coder type-toggle` | `:CoderType` | Show Ask/Agent mode switcher | -| `:Coder logs-toggle` | `:CoderLogs` | Toggle logs panel | +| Command | Description | +|---------|-------------| +| `:CoderLogs` | Toggle logs panel | +| `:CoderType` | Show mode switcher | + +## Keymaps Reference + +### Default Keymaps +| Key | Mode | Description | +|-----|------|-------------| +| `ctt` | Normal | Transform tag at cursor | +| `ctt` | Visual | Transform selected tags | +| `ctT` | Normal | Transform all tags | +| `ca` | Normal | Toggle Agent panel | +| `ci` | Normal | Open coder companion | + +### Ask Panel Keymaps +| Key | Description | +|-----|-------------| +| `@` | Attach file | +| `Ctrl+Enter` | Submit | +| `Ctrl+n` | New chat | +| `Ctrl+f` | Add current file | +| `q` | Close | +| `Y` | Copy response | + +### Agent Panel Keymaps +| Key | Description | +|-----|-------------| +| `` | Submit | +| `Ctrl+c` | Stop agent | +| `q` | Close | + +### Logs Panel Keymaps +| Key | Description | +|-----|-------------| +| `q`/`` | Close | + +### Cost Window Keymaps +| Key | Description | +|-----|-------------| +| `q`/`` | Close | +| `r` | Refresh | +| `c` | Clear session | +| `C` | Clear all | ## Configuration Schema ```lua { llm = { - provider = "claude", -- "claude" | "openai" | "gemini" | "copilot" | "ollama" - claude = { - api_key = nil, -- string, uses ANTHROPIC_API_KEY env if nil - model = "claude-sonnet-4-20250514", - }, - openai = { - api_key = nil, -- string, uses OPENAI_API_KEY env if nil - model = "gpt-4o", - endpoint = nil, -- custom endpoint for Azure, OpenRouter, etc. - }, - gemini = { - api_key = nil, -- string, uses GEMINI_API_KEY env if nil - model = "gemini-2.0-flash", - }, - copilot = { - model = "gpt-4o", -- uses OAuth from copilot.lua/copilot.vim - }, - ollama = { - host = "http://localhost:11434", - model = "deepseek-coder:6.7b", - }, + provider = "claude", + claude = { api_key = nil, model = "claude-sonnet-4-20250514" }, + openai = { api_key = nil, model = "gpt-4o", endpoint = nil }, + gemini = { api_key = nil, model = "gemini-2.0-flash" }, + copilot = { model = "gpt-4o" }, + ollama = { host = "http://localhost:11434", model = "deepseek-coder:6.7b" }, }, window = { - width = 25, -- percentage (25 = 25% of screen) - position = "left", -- "left" | "right" + width = 25, + position = "left", border = "rounded", }, patterns = { @@ -381,13 +409,14 @@ All commands can be invoked via `:Coder {subcommand}` or dedicated aliases. }, auto_gitignore = true, auto_open_ask = true, - auto_index = false, -- auto-create coder companion files + auto_index = false, scheduler = { - enabled = true, -- enable event-driven scheduler - ollama_scout = true, -- use Ollama as fast scout + enabled = true, + ollama_scout = true, escalation_threshold = 0.7, max_concurrent = 2, completion_delay_ms = 100, + apply_delay_ms = 5000, }, } ``` @@ -396,29 +425,26 @@ All commands can be invoked via `:Coder {subcommand}` or dedicated aliases. ### Claude API - Endpoint: `https://api.anthropic.com/v1/messages` -- Uses `x-api-key` header for authentication -- Supports tool use for agent mode +- Auth: `x-api-key` header +- Supports tool use ### OpenAI API - Endpoint: `https://api.openai.com/v1/chat/completions` (configurable) -- Uses `Authorization: Bearer` header -- Supports tool use for agent mode -- Compatible with Azure, OpenRouter, and other OpenAI-compatible APIs +- Auth: `Authorization: Bearer` +- Compatible with Azure, OpenRouter ### Gemini API - Endpoint: `https://generativelanguage.googleapis.com/v1beta/models` -- Uses API key in URL parameter -- Supports function calling for agent mode +- Auth: API key in URL +- Supports function calling ### Copilot API - Uses GitHub OAuth token from copilot.lua/copilot.vim -- Endpoint from token response (typically `api.githubcopilot.com`) - OpenAI-compatible format ### Ollama API - Endpoint: `{host}/api/generate` or `{host}/api/chat` -- No authentication required for local instances -- Tool use via prompt-based approach +- No auth required locally ## Agent Tool Definitions @@ -431,13 +457,6 @@ tools = { } ``` -## Code Injection Strategies - -1. **Refactor**: Replace entire file content -2. **Add**: Insert at cursor position in target file -3. **Document**: Insert above current function/class -4. **Generic**: Prompt user for action - ## File Naming Convention | Target File | Coder File | @@ -450,8 +469,8 @@ Pattern: `name.coder.extension` ## Dependencies -- **Required**: Neovim >= 0.8.0, curl -- **Optional**: telescope.nvim (enhanced file picker), copilot.lua or copilot.vim (for Copilot provider) +- **Required**: Neovim >= 0.8.0, curl, plenary.nvim, nvim-treesitter +- **Optional**: telescope.nvim, copilot.lua/copilot.vim, nui.nvim ## Contact diff --git a/lua/codetyper/agent/agentic.lua b/lua/codetyper/agent/agentic.lua index fced368..10e4a13 100644 --- a/lua/codetyper/agent/agentic.lua +++ b/lua/codetyper/agent/agentic.lua @@ -1,7 +1,7 @@ ---@mod codetyper.agent.agentic Agentic loop with proper tool calling ---@brief [[ --- Full agentic system that handles multi-file changes via tool calling. ---- Inspired by avante.nvim and opencode patterns. +--- Multi-file agent system with tool orchestration. ---@brief ]] local M = {} diff --git a/lua/codetyper/agent/conflict.lua b/lua/codetyper/agent/conflict.lua new file mode 100644 index 0000000..5523153 --- /dev/null +++ b/lua/codetyper/agent/conflict.lua @@ -0,0 +1,1071 @@ +---@mod codetyper.agent.conflict Git conflict-style diff visualization +---@brief [[ +--- Provides interactive conflict resolution for AI-generated code changes. +--- Uses git merge conflict markers (<<<<<<< / ======= / >>>>>>>) with +--- extmark highlighting for visual differentiation. +--- +--- Keybindings in conflict buffers: +--- co = accept "ours" (keep original code) +--- ct = accept "theirs" (use AI suggestion) +--- cb = accept "both" (keep both versions) +--- cn = accept "none" (delete both versions) +--- [x = jump to previous conflict +--- ]x = jump to next conflict +---@brief ]] + +local M = {} + +--- Lazy load linter module +local function get_linter() + return require("codetyper.agent.linter") +end + +--- Configuration +local config = { + -- Run linter check after accepting AI suggestions + lint_after_accept = true, + -- Auto-fix lint errors without prompting + auto_fix_lint_errors = true, + -- Auto-show menu after injecting conflict + auto_show_menu = true, + -- Auto-show menu for next conflict after resolving one + auto_show_next_menu = true, +} + +--- Namespace for conflict highlighting +local NAMESPACE = vim.api.nvim_create_namespace("codetyper_conflict") + +--- Namespace for keybinding hints +local HINT_NAMESPACE = vim.api.nvim_create_namespace("codetyper_conflict_hints") + +--- Highlight groups +local HL_GROUPS = { + current = "CoderConflictCurrent", + current_label = "CoderConflictCurrentLabel", + incoming = "CoderConflictIncoming", + incoming_label = "CoderConflictIncomingLabel", + separator = "CoderConflictSeparator", + hint = "CoderConflictHint", +} + +--- Conflict markers +local MARKERS = { + current_start = "<<<<<<< CURRENT", + separator = "=======", + incoming_end = ">>>>>>> INCOMING", +} + +--- Track buffers with active conflicts +---@type table +local conflict_buffers = {} + +--- Run linter validation after accepting code changes +---@param bufnr number Buffer number +---@param start_line number Start line of changed region +---@param end_line number End line of changed region +---@param accepted_type string Type of acceptance ("theirs", "both") +local function validate_after_accept(bufnr, start_line, end_line, accepted_type) + if not config.lint_after_accept then + return + end + + -- Only validate when accepting AI suggestions + if accepted_type ~= "theirs" and accepted_type ~= "both" then + return + end + + local linter = get_linter() + + -- Validate the changed region + linter.validate_after_injection(bufnr, start_line, end_line, function(result) + if not result then + return + end + + -- If errors found and auto-fix is enabled, queue fix automatically + if result.has_errors and config.auto_fix_lint_errors then + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = "Auto-queuing fix for lint errors...", + }) + end) + linter.request_ai_fix(bufnr, result) + end + end) +end + +--- Configure conflict behavior +---@param opts table Configuration options +function M.configure(opts) + for k, v in pairs(opts) do + if config[k] ~= nil then + config[k] = v + end + end +end + +--- Get current configuration +---@return table +function M.get_config() + return vim.deepcopy(config) +end + +--- Auto-show menu for next conflict if enabled and conflicts remain +---@param bufnr number Buffer number +local function auto_show_next_conflict_menu(bufnr) + if not config.auto_show_next_menu then + return + end + + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local conflicts = M.detect_conflicts(bufnr) + if #conflicts > 0 then + -- Jump to first remaining conflict and show menu + local conflict = conflicts[1] + local win = vim.api.nvim_get_current_win() + if vim.api.nvim_win_get_buf(win) == bufnr then + vim.api.nvim_win_set_cursor(win, { conflict.start_line, 0 }) + vim.cmd("normal! zz") + M.show_floating_menu(bufnr) + end + end + end) +end + +--- Setup highlight groups +local function setup_highlights() + -- Current (original) code - green tint + vim.api.nvim_set_hl(0, HL_GROUPS.current, { + bg = "#2d4a3e", + default = true, + }) + vim.api.nvim_set_hl(0, HL_GROUPS.current_label, { + fg = "#98c379", + bg = "#2d4a3e", + bold = true, + default = true, + }) + + -- Incoming (AI suggestion) code - blue tint + vim.api.nvim_set_hl(0, HL_GROUPS.incoming, { + bg = "#2d3a4a", + default = true, + }) + vim.api.nvim_set_hl(0, HL_GROUPS.incoming_label, { + fg = "#61afef", + bg = "#2d3a4a", + bold = true, + default = true, + }) + + -- Separator line + vim.api.nvim_set_hl(0, HL_GROUPS.separator, { + fg = "#5c6370", + bg = "#3e4451", + bold = true, + default = true, + }) + + -- Keybinding hints + vim.api.nvim_set_hl(0, HL_GROUPS.hint, { + fg = "#5c6370", + italic = true, + default = true, + }) +end + +--- Parse a buffer and find all conflict regions +---@param bufnr number Buffer number +---@return table[] conflicts List of conflict positions +function M.detect_conflicts(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return {} + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local conflicts = {} + local current_conflict = nil + + for i, line in ipairs(lines) do + if line:match("^<<<<<<<") then + current_conflict = { + start_line = i, + current_start = i, + current_end = nil, + separator = nil, + incoming_start = nil, + incoming_end = nil, + end_line = nil, + } + elseif line:match("^=======") and current_conflict then + current_conflict.current_end = i - 1 + current_conflict.separator = i + current_conflict.incoming_start = i + 1 + elseif line:match("^>>>>>>>") and current_conflict then + current_conflict.incoming_end = i - 1 + current_conflict.end_line = i + table.insert(conflicts, current_conflict) + current_conflict = nil + end + end + + return conflicts +end + +--- Highlight conflicts in buffer using extmarks +---@param bufnr number Buffer number +---@param conflicts table[] Conflict positions +function M.highlight_conflicts(bufnr, conflicts) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + -- Clear existing highlights + vim.api.nvim_buf_clear_namespace(bufnr, NAMESPACE, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, HINT_NAMESPACE, 0, -1) + + for _, conflict in ipairs(conflicts) do + -- Highlight <<<<<<< CURRENT line + vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, conflict.start_line - 1, 0, { + end_row = conflict.start_line - 1, + end_col = 0, + line_hl_group = HL_GROUPS.current_label, + priority = 100, + }) + + -- Highlight current (original) code section + if conflict.current_start and conflict.current_end then + for row = conflict.current_start, conflict.current_end do + if row <= conflict.current_end then + vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, row - 1, 0, { + end_row = row - 1, + end_col = 0, + line_hl_group = HL_GROUPS.current, + priority = 90, + }) + end + end + end + + -- Highlight ======= separator + if conflict.separator then + vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, conflict.separator - 1, 0, { + end_row = conflict.separator - 1, + end_col = 0, + line_hl_group = HL_GROUPS.separator, + priority = 100, + }) + end + + -- Highlight incoming (AI suggestion) code section + if conflict.incoming_start and conflict.incoming_end then + for row = conflict.incoming_start, conflict.incoming_end do + if row <= conflict.incoming_end then + vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, row - 1, 0, { + end_row = row - 1, + end_col = 0, + line_hl_group = HL_GROUPS.incoming, + priority = 90, + }) + end + end + end + + -- Highlight >>>>>>> INCOMING line + if conflict.end_line then + vim.api.nvim_buf_set_extmark(bufnr, NAMESPACE, conflict.end_line - 1, 0, { + end_row = conflict.end_line - 1, + end_col = 0, + line_hl_group = HL_GROUPS.incoming_label, + priority = 100, + }) + end + + -- Add virtual text hint on the <<<<<<< line + vim.api.nvim_buf_set_extmark(bufnr, HINT_NAMESPACE, conflict.start_line - 1, 0, { + virt_text = { + { " [co]=ours [ct]=theirs [cb]=both [cn]=none [x/]x=nav", HL_GROUPS.hint }, + }, + virt_text_pos = "eol", + priority = 50, + }) + end +end + +--- Get the conflict at the current cursor position +---@param bufnr number Buffer number +---@param cursor_line number Current line (1-indexed) +---@return table|nil conflict The conflict at cursor, or nil +function M.get_conflict_at_cursor(bufnr, cursor_line) + local conflicts = M.detect_conflicts(bufnr) + + for _, conflict in ipairs(conflicts) do + if cursor_line >= conflict.start_line and cursor_line <= conflict.end_line then + return conflict + end + end + + return nil +end + +--- Accept "ours" - keep the original code +---@param bufnr number Buffer number +function M.accept_ours(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) + + if not conflict then + vim.notify("No conflict at cursor position", vim.log.levels.WARN) + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Extract the "current" (original) lines + local keep_lines = {} + if conflict.current_start and conflict.current_end then + for i = conflict.current_start + 1, conflict.current_end do + table.insert(keep_lines, lines[i]) + end + end + + -- Replace the entire conflict region with the kept lines + vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, keep_lines) + + -- Re-process remaining conflicts + M.process(bufnr) + + vim.notify("Accepted CURRENT (original) code", vim.log.levels.INFO) + + -- Auto-show menu for next conflict if any remain + auto_show_next_conflict_menu(bufnr) +end + +--- Accept "theirs" - use the AI suggestion +---@param bufnr number Buffer number +function M.accept_theirs(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) + + if not conflict then + vim.notify("No conflict at cursor position", vim.log.levels.WARN) + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Extract the "incoming" (AI suggestion) lines + local keep_lines = {} + if conflict.incoming_start and conflict.incoming_end then + for i = conflict.incoming_start, conflict.incoming_end do + table.insert(keep_lines, lines[i]) + end + end + + -- Track where the code will be inserted + local insert_start = conflict.start_line + local insert_end = insert_start + #keep_lines - 1 + + -- Replace the entire conflict region with the kept lines + vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, keep_lines) + + -- Re-process remaining conflicts + M.process(bufnr) + + vim.notify("Accepted INCOMING (AI suggestion) code", vim.log.levels.INFO) + + -- Run linter validation on the accepted code + validate_after_accept(bufnr, insert_start, insert_end, "theirs") + + -- Auto-show menu for next conflict if any remain + auto_show_next_conflict_menu(bufnr) +end + +--- Accept "both" - keep both versions +---@param bufnr number Buffer number +function M.accept_both(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) + + if not conflict then + vim.notify("No conflict at cursor position", vim.log.levels.WARN) + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Extract both "current" and "incoming" lines + local keep_lines = {} + + -- Add current lines + if conflict.current_start and conflict.current_end then + for i = conflict.current_start + 1, conflict.current_end do + table.insert(keep_lines, lines[i]) + end + end + + -- Add incoming lines + if conflict.incoming_start and conflict.incoming_end then + for i = conflict.incoming_start, conflict.incoming_end do + table.insert(keep_lines, lines[i]) + end + end + + -- Track where the code will be inserted + local insert_start = conflict.start_line + local insert_end = insert_start + #keep_lines - 1 + + -- Replace the entire conflict region with the kept lines + vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, keep_lines) + + -- Re-process remaining conflicts + M.process(bufnr) + + vim.notify("Accepted BOTH (current + incoming) code", vim.log.levels.INFO) + + -- Run linter validation on the accepted code + validate_after_accept(bufnr, insert_start, insert_end, "both") + + -- Auto-show menu for next conflict if any remain + auto_show_next_conflict_menu(bufnr) +end + +--- Accept "none" - delete both versions +---@param bufnr number Buffer number +function M.accept_none(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) + + if not conflict then + vim.notify("No conflict at cursor position", vim.log.levels.WARN) + return + end + + -- Replace the entire conflict region with nothing + vim.api.nvim_buf_set_lines(bufnr, conflict.start_line - 1, conflict.end_line, false, {}) + + -- Re-process remaining conflicts + M.process(bufnr) + + vim.notify("Deleted conflict (accepted NONE)", vim.log.levels.INFO) + + -- Auto-show menu for next conflict if any remain + auto_show_next_conflict_menu(bufnr) +end + +--- Navigate to the next conflict +---@param bufnr number Buffer number +---@return boolean found Whether a conflict was found +function M.goto_next(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] + local conflicts = M.detect_conflicts(bufnr) + + for _, conflict in ipairs(conflicts) do + if conflict.start_line > cursor_line then + vim.api.nvim_win_set_cursor(0, { conflict.start_line, 0 }) + vim.cmd("normal! zz") + return true + end + end + + -- Wrap around to first conflict + if #conflicts > 0 then + vim.api.nvim_win_set_cursor(0, { conflicts[1].start_line, 0 }) + vim.cmd("normal! zz") + vim.notify("Wrapped to first conflict", vim.log.levels.INFO) + return true + end + + vim.notify("No more conflicts", vim.log.levels.INFO) + return false +end + +--- Navigate to the previous conflict +---@param bufnr number Buffer number +---@return boolean found Whether a conflict was found +function M.goto_prev(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local cursor_line = cursor[1] + local conflicts = M.detect_conflicts(bufnr) + + for i = #conflicts, 1, -1 do + local conflict = conflicts[i] + if conflict.start_line < cursor_line then + vim.api.nvim_win_set_cursor(0, { conflict.start_line, 0 }) + vim.cmd("normal! zz") + return true + end + end + + -- Wrap around to last conflict + if #conflicts > 0 then + vim.api.nvim_win_set_cursor(0, { conflicts[#conflicts].start_line, 0 }) + vim.cmd("normal! zz") + vim.notify("Wrapped to last conflict", vim.log.levels.INFO) + return true + end + + vim.notify("No more conflicts", vim.log.levels.INFO) + return false +end + +--- Show conflict resolution menu modal +---@param bufnr number Buffer number +function M.show_menu(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) + + if not conflict then + vim.notify("No conflict at cursor position", vim.log.levels.WARN) + return + end + + -- Get preview of both versions + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + local current_preview = "" + if conflict.current_start and conflict.current_end then + local current_lines = {} + for i = conflict.current_start + 1, math.min(conflict.current_end, conflict.current_start + 3) do + if lines[i] then + table.insert(current_lines, " " .. lines[i]:sub(1, 50)) + end + end + if conflict.current_end - conflict.current_start > 3 then + table.insert(current_lines, " ...") + end + current_preview = table.concat(current_lines, "\n") + end + + local incoming_preview = "" + if conflict.incoming_start and conflict.incoming_end then + local incoming_lines = {} + for i = conflict.incoming_start, math.min(conflict.incoming_end, conflict.incoming_start + 2) do + if lines[i] then + table.insert(incoming_lines, " " .. lines[i]:sub(1, 50)) + end + end + if conflict.incoming_end - conflict.incoming_start > 3 then + table.insert(incoming_lines, " ...") + end + incoming_preview = table.concat(incoming_lines, "\n") + end + + -- Count lines in each section + local current_count = conflict.current_end and conflict.current_start + and (conflict.current_end - conflict.current_start) or 0 + local incoming_count = conflict.incoming_end and conflict.incoming_start + and (conflict.incoming_end - conflict.incoming_start + 1) or 0 + + -- Build menu options + local options = { + { + label = string.format("Accept CURRENT (original) - %d lines", current_count), + key = "co", + action = function() M.accept_ours(bufnr) end, + preview = current_preview, + }, + { + label = string.format("Accept INCOMING (AI suggestion) - %d lines", incoming_count), + key = "ct", + action = function() M.accept_theirs(bufnr) end, + preview = incoming_preview, + }, + { + label = string.format("Accept BOTH versions - %d lines total", current_count + incoming_count), + key = "cb", + action = function() M.accept_both(bufnr) end, + }, + { + label = "Delete conflict (accept NONE)", + key = "cn", + action = function() M.accept_none(bufnr) end, + }, + { + label = "─────────────────────────", + key = "", + action = nil, + separator = true, + }, + { + label = "Next conflict", + key = "]x", + action = function() M.goto_next(bufnr) end, + }, + { + label = "Previous conflict", + key = "[x", + action = function() M.goto_prev(bufnr) end, + }, + } + + -- Build display labels + local labels = {} + for _, opt in ipairs(options) do + if opt.separator then + table.insert(labels, opt.label) + else + table.insert(labels, string.format("[%s] %s", opt.key, opt.label)) + end + end + + -- Show menu using vim.ui.select + vim.ui.select(labels, { + prompt = "Resolve Conflict:", + format_item = function(item) + return item + end, + }, function(choice, idx) + if not choice or not idx then + return + end + + local selected = options[idx] + if selected and selected.action then + selected.action() + end + end) +end + +--- Show floating window menu for conflict resolution +---@param bufnr number Buffer number +function M.show_floating_menu(bufnr) + local cursor = vim.api.nvim_win_get_cursor(0) + local conflict = M.get_conflict_at_cursor(bufnr, cursor[1]) + + if not conflict then + vim.notify("No conflict at cursor position", vim.log.levels.WARN) + return + end + + -- Get lines for preview + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Count lines + local current_count = conflict.current_end and conflict.current_start + and (conflict.current_end - conflict.current_start) or 0 + local incoming_count = conflict.incoming_end and conflict.incoming_start + and (conflict.incoming_end - conflict.incoming_start + 1) or 0 + + -- Build menu content + local menu_lines = { + "╭─────────────────────────────────────────╮", + "│ Resolve Conflict │", + "├─────────────────────────────────────────┤", + string.format("│ [co] Accept CURRENT (original) %3d lines│", current_count), + string.format("│ [ct] Accept INCOMING (AI) %3d lines│", incoming_count), + string.format("│ [cb] Accept BOTH %3d lines│", current_count + incoming_count), + "│ [cn] Delete conflict (NONE) │", + "├─────────────────────────────────────────┤", + "│ []x] Next conflict │", + "│ [[x] Previous conflict │", + "│ [q] Close menu │", + "╰─────────────────────────────────────────╯", + } + + -- Create floating window + local width = 43 + local height = #menu_lines + + local float_opts = { + relative = "cursor", + row = 1, + col = 0, + width = width, + height = height, + style = "minimal", + border = "none", + focusable = true, + } + + -- Create buffer for menu + local menu_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(menu_bufnr, 0, -1, false, menu_lines) + vim.bo[menu_bufnr].modifiable = false + vim.bo[menu_bufnr].bufhidden = "wipe" + + -- Open floating window + local win = vim.api.nvim_open_win(menu_bufnr, true, float_opts) + + -- Set highlights + vim.api.nvim_set_hl(0, "CoderConflictMenuBorder", { fg = "#61afef", default = true }) + vim.api.nvim_set_hl(0, "CoderConflictMenuTitle", { fg = "#e5c07b", bold = true, default = true }) + vim.api.nvim_set_hl(0, "CoderConflictMenuKey", { fg = "#98c379", bold = true, default = true }) + + vim.wo[win].winhl = "Normal:Normal,FloatBorder:CoderConflictMenuBorder" + + -- Add syntax highlighting to menu buffer + vim.api.nvim_buf_add_highlight(menu_bufnr, -1, "CoderConflictMenuTitle", 1, 0, -1) + for i = 3, 9 do + -- Highlight the key in brackets + local line = menu_lines[i + 1] + if line then + local start_col = line:find("%[") + local end_col = line:find("%]") + if start_col and end_col then + vim.api.nvim_buf_add_highlight(menu_bufnr, -1, "CoderConflictMenuKey", i, start_col - 1, end_col) + end + end + end + + -- Setup keymaps for the menu + local close_menu = function() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + end + + -- Use nowait to prevent delay from built-in 'c' command + local menu_opts = { buffer = menu_bufnr, silent = true, noremap = true, nowait = true } + + vim.keymap.set("n", "q", close_menu, menu_opts) + vim.keymap.set("n", "", close_menu, menu_opts) + + vim.keymap.set("n", "co", function() + close_menu() + M.accept_ours(bufnr) + end, menu_opts) + + vim.keymap.set("n", "ct", function() + close_menu() + M.accept_theirs(bufnr) + end, menu_opts) + + vim.keymap.set("n", "cb", function() + close_menu() + M.accept_both(bufnr) + end, menu_opts) + + vim.keymap.set("n", "cn", function() + close_menu() + M.accept_none(bufnr) + end, menu_opts) + + vim.keymap.set("n", "]x", function() + close_menu() + M.goto_next(bufnr) + end, menu_opts) + + vim.keymap.set("n", "[x", function() + close_menu() + M.goto_prev(bufnr) + end, menu_opts) + + -- Also support number keys for quick selection + vim.keymap.set("n", "1", function() + close_menu() + M.accept_ours(bufnr) + end, menu_opts) + + vim.keymap.set("n", "2", function() + close_menu() + M.accept_theirs(bufnr) + end, menu_opts) + + vim.keymap.set("n", "3", function() + close_menu() + M.accept_both(bufnr) + end, menu_opts) + + vim.keymap.set("n", "4", function() + close_menu() + M.accept_none(bufnr) + end, menu_opts) + + -- Close on focus lost + vim.api.nvim_create_autocmd("WinLeave", { + buffer = menu_bufnr, + once = true, + callback = close_menu, + }) +end + +--- Setup keybindings for conflict resolution in a buffer +---@param bufnr number Buffer number +function M.setup_keymaps(bufnr) + -- Use nowait to prevent delay from built-in 'c' command + local opts = { buffer = bufnr, silent = true, noremap = true, nowait = true } + + -- Accept ours (original) + vim.keymap.set("n", "co", function() + M.accept_ours(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Accept CURRENT (original) code" })) + + -- Accept theirs (AI suggestion) + vim.keymap.set("n", "ct", function() + M.accept_theirs(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Accept INCOMING (AI suggestion) code" })) + + -- Accept both + vim.keymap.set("n", "cb", function() + M.accept_both(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Accept BOTH versions" })) + + -- Accept none + vim.keymap.set("n", "cn", function() + M.accept_none(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Delete conflict (accept NONE)" })) + + -- Navigate to next conflict + vim.keymap.set("n", "]x", function() + M.goto_next(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Go to next conflict" })) + + -- Navigate to previous conflict + vim.keymap.set("n", "[x", function() + M.goto_prev(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Go to previous conflict" })) + + -- Show menu modal + vim.keymap.set("n", "cm", function() + M.show_floating_menu(bufnr) + end, vim.tbl_extend("force", opts, { desc = "Show conflict resolution menu" })) + + -- Also map to show menu when on conflict + vim.keymap.set("n", "", function() + local cursor = vim.api.nvim_win_get_cursor(0) + if M.get_conflict_at_cursor(bufnr, cursor[1]) then + M.show_floating_menu(bufnr) + else + -- Default behavior + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false) + end + end, vim.tbl_extend("force", opts, { desc = "Show conflict menu or default action" })) + + -- Mark buffer as having conflict keymaps + conflict_buffers[bufnr] = { + keymaps_set = true, + } +end + +--- Remove keybindings from a buffer +---@param bufnr number Buffer number +function M.remove_keymaps(bufnr) + if not conflict_buffers[bufnr] then + return + end + + pcall(vim.keymap.del, "n", "co", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "ct", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "cb", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "cn", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "cm", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "]x", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "[x", { buffer = bufnr }) + pcall(vim.keymap.del, "n", "", { buffer = bufnr }) + + conflict_buffers[bufnr] = nil +end + +--- Insert conflict markers for a code change +---@param bufnr number Buffer number +---@param start_line number Start line (1-indexed) +---@param end_line number End line (1-indexed) +---@param new_lines string[] New lines to insert as "incoming" +---@param label? string Optional label for the incoming section +function M.insert_conflict(bufnr, start_line, end_line, new_lines, label) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + + -- Clamp to valid range + local line_count = #lines + start_line = math.max(1, math.min(start_line, line_count + 1)) + end_line = math.max(start_line, math.min(end_line, line_count)) + + -- Extract current lines + local current_lines = {} + for i = start_line, end_line do + if lines[i] then + table.insert(current_lines, lines[i]) + end + end + + -- Build conflict block + local conflict_block = {} + table.insert(conflict_block, MARKERS.current_start) + for _, line in ipairs(current_lines) do + table.insert(conflict_block, line) + end + table.insert(conflict_block, MARKERS.separator) + for _, line in ipairs(new_lines) do + table.insert(conflict_block, line) + end + table.insert(conflict_block, label and (">>>>>>> " .. label) or MARKERS.incoming_end) + + -- Replace the range with conflict block + vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, conflict_block) +end + +--- Process buffer and auto-show menu for first conflict +--- Call this after inserting conflict(s) to set up highlights and show menu +---@param bufnr number Buffer number +function M.process_and_show_menu(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + -- Process to set up highlights and keymaps + local conflict_count = M.process(bufnr) + + -- Auto-show menu if enabled and conflicts exist + if config.auto_show_menu and conflict_count > 0 then + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + -- Find window showing this buffer and focus it + local win = nil + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(w) == bufnr then + win = w + break + end + end + + if win then + vim.api.nvim_set_current_win(win) + -- Jump to first conflict + local conflicts = M.detect_conflicts(bufnr) + if #conflicts > 0 then + vim.api.nvim_win_set_cursor(win, { conflicts[1].start_line, 0 }) + vim.cmd("normal! zz") + -- Show the menu + M.show_floating_menu(bufnr) + end + end + end) + end +end + +--- Process a buffer for conflicts - detect, highlight, and setup keymaps +---@param bufnr number Buffer number +---@return number conflict_count Number of conflicts found +function M.process(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return 0 + end + + -- Setup highlights if not done + setup_highlights() + + -- Detect conflicts + local conflicts = M.detect_conflicts(bufnr) + + if #conflicts > 0 then + -- Highlight conflicts + M.highlight_conflicts(bufnr, conflicts) + + -- Setup keymaps if not already done + if not conflict_buffers[bufnr] then + M.setup_keymaps(bufnr) + end + + -- Log + pcall(function() + local logs = require("codetyper.agent.logs") + logs.info(string.format("Found %d conflict(s) - use co/ct/cb/cn to resolve, [x/]x to navigate", #conflicts)) + end) + else + -- No conflicts - clean up + vim.api.nvim_buf_clear_namespace(bufnr, NAMESPACE, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, HINT_NAMESPACE, 0, -1) + M.remove_keymaps(bufnr) + end + + return #conflicts +end + +--- Check if a buffer has conflicts +---@param bufnr number Buffer number +---@return boolean +function M.has_conflicts(bufnr) + local conflicts = M.detect_conflicts(bufnr) + return #conflicts > 0 +end + +--- Get conflict count for a buffer +---@param bufnr number Buffer number +---@return number +function M.count_conflicts(bufnr) + local conflicts = M.detect_conflicts(bufnr) + return #conflicts +end + +--- Clear all conflicts from a buffer (remove markers but keep current code) +---@param bufnr number Buffer number +---@param keep "ours"|"theirs"|"both"|"none" Which version to keep +function M.resolve_all(bufnr, keep) + local conflicts = M.detect_conflicts(bufnr) + + -- Process in reverse order to maintain line numbers + for i = #conflicts, 1, -1 do + -- Move cursor to conflict + vim.api.nvim_win_set_cursor(0, { conflicts[i].start_line, 0 }) + + -- Accept based on preference + if keep == "ours" then + M.accept_ours(bufnr) + elseif keep == "theirs" then + M.accept_theirs(bufnr) + elseif keep == "both" then + M.accept_both(bufnr) + else + M.accept_none(bufnr) + end + end +end + +--- Add a buffer to conflict tracking (for auto-follow) +---@param bufnr number Buffer number +function M.add_tracked_buffer(bufnr) + if not conflict_buffers[bufnr] then + conflict_buffers[bufnr] = {} + end +end + +--- Get all tracked buffers with conflicts +---@return number[] buffers List of buffer numbers +function M.get_tracked_buffers() + local buffers = {} + for bufnr, _ in pairs(conflict_buffers) do + if vim.api.nvim_buf_is_valid(bufnr) and M.has_conflicts(bufnr) then + table.insert(buffers, bufnr) + end + end + return buffers +end + +--- Clear tracking for a buffer +---@param bufnr number Buffer number +function M.clear_buffer(bufnr) + vim.api.nvim_buf_clear_namespace(bufnr, NAMESPACE, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, HINT_NAMESPACE, 0, -1) + M.remove_keymaps(bufnr) + conflict_buffers[bufnr] = nil +end + +--- Initialize the conflict module +function M.setup() + setup_highlights() + + -- Auto-clean up when buffers are deleted + vim.api.nvim_create_autocmd("BufDelete", { + group = vim.api.nvim_create_augroup("CoderConflict", { clear = true }), + callback = function(ev) + conflict_buffers[ev.buf] = nil + end, + }) +end + +return M diff --git a/lua/codetyper/agent/context_builder.lua b/lua/codetyper/agent/context_builder.lua new file mode 100644 index 0000000..b8699cd --- /dev/null +++ b/lua/codetyper/agent/context_builder.lua @@ -0,0 +1,302 @@ +---@mod codetyper.agent.context_builder Context builder for agent prompts +--- +--- Builds rich context including project structure, memories, and conventions +--- to help the LLM understand the codebase. + +local M = {} + +local utils = require("codetyper.utils") + +--- Get project structure as a tree string +---@param max_depth? number Maximum depth to traverse (default: 3) +---@param max_files? number Maximum files to show (default: 50) +---@return string Project tree +function M.get_project_structure(max_depth, max_files) + max_depth = max_depth or 3 + max_files = max_files or 50 + + local root = utils.get_project_root() or vim.fn.getcwd() + local lines = { "PROJECT STRUCTURE:", root, "" } + local file_count = 0 + + -- Common ignore patterns + local ignore_patterns = { + "^%.", -- Hidden files/dirs + "node_modules", + "%.git$", + "__pycache__", + "%.pyc$", + "target", -- Rust + "build", + "dist", + "%.o$", + "%.a$", + "%.so$", + "%.min%.", + "%.map$", + } + + local function should_ignore(name) + for _, pattern in ipairs(ignore_patterns) do + if name:match(pattern) then + return true + end + end + return false + end + + local function traverse(path, depth, prefix) + if depth > max_depth or file_count >= max_files then + return + end + + local entries = {} + local handle = vim.loop.fs_scandir(path) + if not handle then + return + end + + while true do + local name, type = vim.loop.fs_scandir_next(handle) + if not name then + break + end + if not should_ignore(name) then + table.insert(entries, { name = name, type = type }) + end + end + + -- Sort: directories first, then alphabetically + table.sort(entries, function(a, b) + if a.type == "directory" and b.type ~= "directory" then + return true + elseif a.type ~= "directory" and b.type == "directory" then + return false + else + return a.name < b.name + end + end) + + for i, entry in ipairs(entries) do + if file_count >= max_files then + table.insert(lines, prefix .. "... (truncated)") + return + end + + local is_last = (i == #entries) + local branch = is_last and "└── " or "├── " + local new_prefix = prefix .. (is_last and " " or "│ ") + + local icon = entry.type == "directory" and "/" or "" + table.insert(lines, prefix .. branch .. entry.name .. icon) + file_count = file_count + 1 + + if entry.type == "directory" then + traverse(path .. "/" .. entry.name, depth + 1, new_prefix) + end + end + end + + traverse(root, 1, "") + + if file_count >= max_files then + table.insert(lines, "") + table.insert(lines, "(Structure truncated at " .. max_files .. " entries)") + end + + return table.concat(lines, "\n") +end + +--- Get key files that are important for understanding the project +---@return table Map of filename to description +function M.get_key_files() + local root = utils.get_project_root() or vim.fn.getcwd() + local key_files = {} + + local important_files = { + ["package.json"] = "Node.js project config", + ["Cargo.toml"] = "Rust project config", + ["go.mod"] = "Go module config", + ["pyproject.toml"] = "Python project config", + ["setup.py"] = "Python setup config", + ["Makefile"] = "Build configuration", + ["CMakeLists.txt"] = "CMake config", + [".gitignore"] = "Git ignore patterns", + ["README.md"] = "Project documentation", + ["init.lua"] = "Neovim plugin entry", + ["plugin.lua"] = "Neovim plugin config", + } + + for filename, desc in pairs(important_files) do + -- Check in root + local path = root .. "/" .. filename + if vim.fn.filereadable(path) == 1 then + key_files[filename] = { path = path, description = desc } + end + + -- Check in lua/ for Neovim plugins + local lua_path = root .. "/lua/" .. filename + if vim.fn.filereadable(lua_path) == 1 then + key_files["lua/" .. filename] = { path = lua_path, description = desc } + end + end + + return key_files +end + +--- Detect project type and language +---@return table { type: string, language: string, framework?: string } +function M.detect_project_type() + local root = utils.get_project_root() or vim.fn.getcwd() + + local indicators = { + ["package.json"] = { type = "node", language = "javascript/typescript" }, + ["Cargo.toml"] = { type = "rust", language = "rust" }, + ["go.mod"] = { type = "go", language = "go" }, + ["pyproject.toml"] = { type = "python", language = "python" }, + ["setup.py"] = { type = "python", language = "python" }, + ["Gemfile"] = { type = "ruby", language = "ruby" }, + ["pom.xml"] = { type = "maven", language = "java" }, + ["build.gradle"] = { type = "gradle", language = "java/kotlin" }, + } + + -- Check for Neovim plugin specifically + if vim.fn.isdirectory(root .. "/lua") == 1 then + local plugin_files = vim.fn.glob(root .. "/plugin/*.lua", false, true) + if #plugin_files > 0 or vim.fn.filereadable(root .. "/init.lua") == 1 then + return { type = "neovim-plugin", language = "lua", framework = "neovim" } + end + end + + for file, info in pairs(indicators) do + if vim.fn.filereadable(root .. "/" .. file) == 1 then + return info + end + end + + return { type = "unknown", language = "unknown" } +end + +--- Get memories/patterns from the brain system +---@return string Formatted memories context +function M.get_memories_context() + local ok_memory, memory = pcall(require, "codetyper.indexer.memory") + if not ok_memory then + return "" + end + + local all = memory.get_all() + if not all then + return "" + end + + local lines = {} + + -- Add patterns + if all.patterns and next(all.patterns) then + table.insert(lines, "LEARNED PATTERNS:") + local count = 0 + for _, mem in pairs(all.patterns) do + if count >= 5 then + break + end + if mem.content then + table.insert(lines, " - " .. mem.content:sub(1, 100)) + count = count + 1 + end + end + table.insert(lines, "") + end + + -- Add conventions + if all.conventions and next(all.conventions) then + table.insert(lines, "CODING CONVENTIONS:") + local count = 0 + for _, mem in pairs(all.conventions) do + if count >= 5 then + break + end + if mem.content then + table.insert(lines, " - " .. mem.content:sub(1, 100)) + count = count + 1 + end + end + table.insert(lines, "") + end + + return table.concat(lines, "\n") +end + +--- Build the full context for agent prompts +---@return string Full context string +function M.build_full_context() + local sections = {} + + -- Project info + local project_type = M.detect_project_type() + table.insert(sections, string.format( + "PROJECT INFO:\n Type: %s\n Language: %s%s\n", + project_type.type, + project_type.language, + project_type.framework and ("\n Framework: " .. project_type.framework) or "" + )) + + -- Project structure + local structure = M.get_project_structure(3, 40) + table.insert(sections, structure) + + -- Key files + local key_files = M.get_key_files() + if next(key_files) then + local key_lines = { "", "KEY FILES:" } + for name, info in pairs(key_files) do + table.insert(key_lines, string.format(" %s - %s", name, info.description)) + end + table.insert(sections, table.concat(key_lines, "\n")) + end + + -- Memories + local memories = M.get_memories_context() + if memories ~= "" then + table.insert(sections, "\n" .. memories) + end + + return table.concat(sections, "\n") +end + +--- Get a compact context summary for token efficiency +---@return string Compact context +function M.build_compact_context() + local root = utils.get_project_root() or vim.fn.getcwd() + local project_type = M.detect_project_type() + + local lines = { + "CONTEXT:", + " Root: " .. root, + " Type: " .. project_type.type .. " (" .. project_type.language .. ")", + } + + -- Add main directories + local main_dirs = {} + local handle = vim.loop.fs_scandir(root) + if handle then + while true do + local name, type = vim.loop.fs_scandir_next(handle) + if not name then + break + end + if type == "directory" and not name:match("^%.") and not name:match("node_modules") then + table.insert(main_dirs, name .. "/") + end + end + end + + if #main_dirs > 0 then + table.sort(main_dirs) + table.insert(lines, " Main dirs: " .. table.concat(main_dirs, ", ")) + end + + return table.concat(lines, "\n") +end + +return M diff --git a/lua/codetyper/agent/context_modal.lua b/lua/codetyper/agent/context_modal.lua index aadcd0d..1b0c069 100644 --- a/lua/codetyper/agent/context_modal.lua +++ b/lua/codetyper/agent/context_modal.lua @@ -19,6 +19,7 @@ local state = { original_event = nil, callback = nil, llm_response = nil, + attached_files = nil, } --- Close the context modal @@ -59,15 +60,99 @@ local function submit() M.close() if callback and original_event then - callback(original_event, additional_context) + -- Pass attached_files as third optional parameter + callback(original_event, additional_context, state.attached_files) end end + +--- Parse requested file paths from LLM response and resolve to full paths +local function parse_requested_files(response) + if not response or response == "" then + return {} + end + + local cwd = vim.fn.getcwd() + local candidates = {} + local seen = {} + + for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do + if not seen[path] then + table.insert(candidates, path) + seen[path] = true + end + end + for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do + if not seen[path] then + table.insert(candidates, path) + seen[path] = true + end + end + + -- Resolve to full paths using cwd and glob + local resolved = {} + for _, p in ipairs(candidates) do + local full = nil + if p:sub(1,1) == "/" and vim.fn.filereadable(p) == 1 then + full = p + else + local try1 = cwd .. "/" .. p + if vim.fn.filereadable(try1) == 1 then + full = try1 + else + local tail = p:match("[^/]+$") or p + local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true) + if matches and #matches > 0 then + full = matches[1] + end + end + end + if full and vim.fn.filereadable(full) == 1 then + table.insert(resolved, full) + end + end + return resolved +end + + +--- Attach parsed files into the modal buffer and remember them for submission +local function attach_requested_files() + if not state.llm_response or state.llm_response == "" then + return + end + local files = parse_requested_files(state.llm_response) + if #files == 0 then + vim.api.nvim_buf_set_lines(state.buf, vim.api.nvim_buf_line_count(state.buf), -1, false, { "", "-- No files detected in LLM response --" }) + return + end + + state.attached_files = state.attached_files or {} + + for _, full in ipairs(files) do + local ok, lines = pcall(vim.fn.readfile, full) + if ok and lines and #lines > 0 then + table.insert(state.attached_files, { path = vim.fn.fnamemodify(full, ":~:." ) , full_path = full, content = table.concat(lines, "\n") }) + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Attached: " .. full .. " --" }) + for i, l in ipairs(lines) do + vim.api.nvim_buf_set_lines(state.buf, insert_at + 1 + i, insert_at + 1 + i, false, { l }) + end + else + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Failed to read: " .. full .. " --" }) + end + end + -- Move cursor to end and enter insert mode + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") +end + --- Open the context modal ---@param original_event table Original prompt event ---@param llm_response string LLM's response asking for context ----@param callback function(event: table, additional_context: string) -function M.open(original_event, llm_response, callback) +---@param callback function(event: table, additional_context: string, attached_files?: table) +---@param suggested_commands table[]|nil Optional list of {label,cmd} suggested shell commands +function M.open(original_event, llm_response, callback, suggested_commands) -- Close any existing modal M.close() @@ -119,6 +204,17 @@ function M.open(original_event, llm_response, callback) table.insert(header_lines, "-- " .. line) end + -- If suggested commands were provided, show them in the header + if suggested_commands and #suggested_commands > 0 then + table.insert(header_lines, "") + table.insert(header_lines, "-- Suggested commands: --") + for i, s in ipairs(suggested_commands) do + local label = s.label or s.cmd + table.insert(header_lines, string.format("[%d] %s: %s", i, label, s.cmd)) + end + table.insert(header_lines, "-- Press to run a command, or r to run all --") + end + table.insert(header_lines, "") table.insert(header_lines, "-- Enter additional context below (Ctrl-Enter to submit, Esc to cancel) --") table.insert(header_lines, "") @@ -137,6 +233,65 @@ function M.open(original_event, llm_response, callback) vim.keymap.set("n", "s", submit, opts) vim.keymap.set("n", "", submit, opts) + -- Attach parsed files (from LLM response) + vim.keymap.set("n", "a", function() + attach_requested_files() + end, opts) + + -- Confirm and submit with 'c' (convenient when doing question round) + vim.keymap.set("n", "c", submit, opts) + + -- Quick run of project inspection from modal with r / in insert mode + vim.keymap.set("n", "r", run_project_inspect, opts) + vim.keymap.set("i", "", function() + vim.schedule(run_project_inspect) + end, { buffer = state.buf, noremap = true, silent = true }) + + -- If suggested commands provided, create per-command keymaps 1..n to run them + state.suggested_commands = suggested_commands + if suggested_commands and #suggested_commands > 0 then + for i, s in ipairs(suggested_commands) do + local key = "" .. tostring(i) + vim.keymap.set("n", key, function() + -- run this single command and append output + if not s or not s.cmd then + return + end + local ok, out = pcall(vim.fn.systemlist, s.cmd) + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" }) + if ok and out and #out > 0 then + for j, line in ipairs(out) do + vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line }) + end + else + vim.api.nvim_buf_set_lines(state.buf, insert_at + 1, insert_at + 1, false, { "(no output or command failed)" }) + end + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") + end, opts) + end + -- Also map 0 to run all suggested commands + vim.keymap.set("n", "0", function() + for _, s in ipairs(suggested_commands) do + pcall(function() + local ok, out = pcall(vim.fn.systemlist, s.cmd) + local insert_at = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_at, insert_at, false, { "", "-- Output: " .. s.cmd .. " --" }) + if ok and out and #out > 0 then + for j, line in ipairs(out) do + vim.api.nvim_buf_set_lines(state.buf, insert_at + j, insert_at + j, false, { line }) + end + else + vim.api.nvim_buf_set_lines(state.buf, insert_at + 1, insert_at + 1, false, { "(no output or command failed)" }) + end + end) + end + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") + end, opts) + end + -- Close with Esc or q vim.keymap.set("n", "", M.close, opts) vim.keymap.set("n", "q", M.close, opts) @@ -154,6 +309,51 @@ function M.open(original_event, llm_response, callback) end) end +--- Run a small set of safe project inspection commands and insert outputs into the modal buffer +local function run_project_inspect() + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then + return + end + + local cmds = { + { label = "List files (ls -la)", cmd = "ls -la" }, + { label = "Git status (git status --porcelain)", cmd = "git status --porcelain" }, + { label = "Git top (git rev-parse --show-toplevel)", cmd = "git rev-parse --show-toplevel" }, + { label = "Show repo files (git ls-files)", cmd = "git ls-files" }, + } + + local insert_pos = vim.api.nvim_buf_line_count(state.buf) + vim.api.nvim_buf_set_lines(state.buf, insert_pos, insert_pos, false, { "", "-- Project inspection results --" }) + + for _, c in ipairs(cmds) do + local ok, out = pcall(vim.fn.systemlist, c.cmd) + if ok and out and #out > 0 then + vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --" }) + for i, line in ipairs(out) do + vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2 + i, insert_pos + 2 + i, false, { line }) + end + insert_pos = vim.api.nvim_buf_line_count(state.buf) + else + vim.api.nvim_buf_set_lines(state.buf, insert_pos + 2, insert_pos + 2, false, { "-- " .. c.label .. " --", "(no output or command failed)" }) + insert_pos = vim.api.nvim_buf_line_count(state.buf) + end + end + + -- Move cursor to end + vim.api.nvim_win_set_cursor(state.win, { vim.api.nvim_buf_line_count(state.buf), 0 }) + vim.cmd("startinsert") +end + +-- Provide a keybinding in the modal to run project inspection commands +pcall(function() + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + vim.keymap.set("n", "r", run_project_inspect, { buffer = state.buf, noremap = true, silent = true }) + vim.keymap.set("i", "", function() + vim.schedule(run_project_inspect) + end, { buffer = state.buf, noremap = true, silent = true }) + end +end) + --- Check if modal is open ---@return boolean function M.is_open() diff --git a/lua/codetyper/agent/diff_review.lua b/lua/codetyper/agent/diff_review.lua new file mode 100644 index 0000000..8944c7e --- /dev/null +++ b/lua/codetyper/agent/diff_review.lua @@ -0,0 +1,384 @@ +---@mod codetyper.agent.diff_review Diff review UI for agent changes +--- +--- Provides a lazygit-style window interface for reviewing all changes +--- made during an agent session. + +local M = {} + +local utils = require("codetyper.utils") + +---@class DiffEntry +---@field path string File path +---@field operation string "create"|"edit"|"delete" +---@field original string|nil Original content (nil for new files) +---@field modified string New/modified content +---@field approved boolean Whether change was approved +---@field applied boolean Whether change was applied + +---@class DiffReviewState +---@field entries DiffEntry[] List of changes +---@field current_index number Currently selected entry +---@field list_buf number|nil File list buffer +---@field list_win number|nil File list window +---@field diff_buf number|nil Diff view buffer +---@field diff_win number|nil Diff view window +---@field is_open boolean Whether review UI is open + +local state = { + entries = {}, + current_index = 1, + list_buf = nil, + list_win = nil, + diff_buf = nil, + diff_win = nil, + is_open = false, +} + +--- Clear all collected diffs +function M.clear() + state.entries = {} + state.current_index = 1 +end + +--- Add a diff entry +---@param entry DiffEntry +function M.add(entry) + table.insert(state.entries, entry) +end + +--- Get all entries +---@return DiffEntry[] +function M.get_entries() + return state.entries +end + +--- Get entry count +---@return number +function M.count() + return #state.entries +end + +--- Generate unified diff between two strings +---@param original string|nil +---@param modified string +---@param filepath string +---@return string[] +local function generate_diff_lines(original, modified, filepath) + local lines = {} + local filename = vim.fn.fnamemodify(filepath, ":t") + + if not original then + -- New file + table.insert(lines, "--- /dev/null") + table.insert(lines, "+++ b/" .. filename) + table.insert(lines, "@@ -0,0 +1," .. #vim.split(modified, "\n") .. " @@") + for _, line in ipairs(vim.split(modified, "\n")) do + table.insert(lines, "+" .. line) + end + else + -- Modified file - use vim's diff + table.insert(lines, "--- a/" .. filename) + table.insert(lines, "+++ b/" .. filename) + + local orig_lines = vim.split(original, "\n") + local mod_lines = vim.split(modified, "\n") + + -- Simple diff: show removed and added lines + local max_lines = math.max(#orig_lines, #mod_lines) + local context_start = 1 + local in_change = false + + for i = 1, max_lines do + local orig = orig_lines[i] or "" + local mod = mod_lines[i] or "" + + if orig ~= mod then + if not in_change then + table.insert(lines, string.format("@@ -%d,%d +%d,%d @@", + math.max(1, i - 2), math.min(5, #orig_lines - i + 3), + math.max(1, i - 2), math.min(5, #mod_lines - i + 3))) + in_change = true + end + if orig ~= "" then + table.insert(lines, "-" .. orig) + end + if mod ~= "" then + table.insert(lines, "+" .. mod) + end + else + if in_change then + table.insert(lines, " " .. orig) + in_change = false + end + end + end + end + + return lines +end + +--- Update the diff view for current entry +local function update_diff_view() + if not state.diff_buf or not vim.api.nvim_buf_is_valid(state.diff_buf) then + return + end + + local entry = state.entries[state.current_index] + if not entry then + vim.bo[state.diff_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, { "No changes to review" }) + vim.bo[state.diff_buf].modifiable = false + return + end + + local lines = {} + + -- Header + local status_icon = entry.applied and "" or (entry.approved and "" or "") + local op_icon = entry.operation == "create" and "+" or (entry.operation == "delete" and "-" or "~") + + table.insert(lines, string.format("╭─ %s %s %s ─────────────────────────────────────", + status_icon, op_icon, vim.fn.fnamemodify(entry.path, ":t"))) + table.insert(lines, "│ " .. entry.path) + table.insert(lines, "│ Operation: " .. entry.operation) + table.insert(lines, "│ Status: " .. (entry.applied and "Applied" or (entry.approved and "Approved" or "Pending"))) + table.insert(lines, "╰────────────────────────────────────────────────────") + table.insert(lines, "") + + -- Diff content + local diff_lines = generate_diff_lines(entry.original, entry.modified, entry.path) + for _, line in ipairs(diff_lines) do + table.insert(lines, line) + end + + vim.bo[state.diff_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, lines) + vim.bo[state.diff_buf].modifiable = false + vim.bo[state.diff_buf].filetype = "diff" +end + +--- Update the file list +local function update_file_list() + if not state.list_buf or not vim.api.nvim_buf_is_valid(state.list_buf) then + return + end + + local lines = { + "╭─ Changes (" .. #state.entries .. ") ──────────╮", + "│ │", + "│ j/k: navigate │", + "│ Enter: view diff │", + "│ a: approve r: reject │", + "│ A: approve all │", + "│ q: close │", + "╰──────────────────────────────╯", + "", + } + + for i, entry in ipairs(state.entries) do + local prefix = (i == state.current_index) and "▶ " or " " + local status = entry.applied and "" or (entry.approved and "" or "○") + local op = entry.operation == "create" and "[+]" or (entry.operation == "delete" and "[-]" or "[~]") + local filename = vim.fn.fnamemodify(entry.path, ":t") + + table.insert(lines, string.format("%s%s %s %s", prefix, status, op, filename)) + end + + if #state.entries == 0 then + table.insert(lines, " No changes to review") + end + + vim.bo[state.list_buf].modifiable = true + vim.api.nvim_buf_set_lines(state.list_buf, 0, -1, false, lines) + vim.bo[state.list_buf].modifiable = false + + -- Highlight current line + if state.list_win and vim.api.nvim_win_is_valid(state.list_win) then + local target_line = 9 + state.current_index - 1 + if target_line <= vim.api.nvim_buf_line_count(state.list_buf) then + vim.api.nvim_win_set_cursor(state.list_win, { target_line, 0 }) + end + end +end + +--- Navigate to next entry +function M.next() + if state.current_index < #state.entries then + state.current_index = state.current_index + 1 + update_file_list() + update_diff_view() + end +end + +--- Navigate to previous entry +function M.prev() + if state.current_index > 1 then + state.current_index = state.current_index - 1 + update_file_list() + update_diff_view() + end +end + +--- Approve current entry +function M.approve_current() + local entry = state.entries[state.current_index] + if entry and not entry.applied then + entry.approved = true + update_file_list() + update_diff_view() + end +end + +--- Reject current entry +function M.reject_current() + local entry = state.entries[state.current_index] + if entry and not entry.applied then + entry.approved = false + update_file_list() + update_diff_view() + end +end + +--- Approve all entries +function M.approve_all() + for _, entry in ipairs(state.entries) do + if not entry.applied then + entry.approved = true + end + end + update_file_list() + update_diff_view() +end + +--- Apply approved changes +function M.apply_approved() + local applied_count = 0 + + for _, entry in ipairs(state.entries) do + if entry.approved and not entry.applied then + if entry.operation == "create" or entry.operation == "edit" then + local ok = utils.write_file(entry.path, entry.modified) + if ok then + entry.applied = true + applied_count = applied_count + 1 + end + elseif entry.operation == "delete" then + local ok = os.remove(entry.path) + if ok then + entry.applied = true + applied_count = applied_count + 1 + end + end + end + end + + update_file_list() + update_diff_view() + + if applied_count > 0 then + utils.notify(string.format("Applied %d change(s)", applied_count)) + end + + return applied_count +end + +--- Open the diff review UI +function M.open() + if state.is_open then + return + end + + if #state.entries == 0 then + utils.notify("No changes to review", vim.log.levels.INFO) + return + end + + -- Create list buffer + state.list_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.list_buf].buftype = "nofile" + vim.bo[state.list_buf].bufhidden = "wipe" + vim.bo[state.list_buf].swapfile = false + + -- Create diff buffer + state.diff_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.diff_buf].buftype = "nofile" + vim.bo[state.diff_buf].bufhidden = "wipe" + vim.bo[state.diff_buf].swapfile = false + + -- Create layout: list on left (30 cols), diff on right + vim.cmd("tabnew") + state.diff_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(state.diff_win, state.diff_buf) + + vim.cmd("topleft vsplit") + state.list_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(state.list_win, state.list_buf) + vim.api.nvim_win_set_width(state.list_win, 35) + + -- Window options + for _, win in ipairs({ state.list_win, state.diff_win }) do + vim.wo[win].number = false + vim.wo[win].relativenumber = false + vim.wo[win].signcolumn = "no" + vim.wo[win].wrap = false + vim.wo[win].cursorline = true + end + + -- Set up keymaps for list buffer + local list_opts = { buffer = state.list_buf, noremap = true, silent = true } + vim.keymap.set("n", "j", M.next, list_opts) + vim.keymap.set("n", "k", M.prev, list_opts) + vim.keymap.set("n", "", M.next, list_opts) + vim.keymap.set("n", "", M.prev, list_opts) + vim.keymap.set("n", "", function() vim.api.nvim_set_current_win(state.diff_win) end, list_opts) + vim.keymap.set("n", "a", M.approve_current, list_opts) + vim.keymap.set("n", "r", M.reject_current, list_opts) + vim.keymap.set("n", "A", M.approve_all, list_opts) + vim.keymap.set("n", "q", M.close, list_opts) + vim.keymap.set("n", "", M.close, list_opts) + + -- Set up keymaps for diff buffer + local diff_opts = { buffer = state.diff_buf, noremap = true, silent = true } + vim.keymap.set("n", "j", M.next, diff_opts) + vim.keymap.set("n", "k", M.prev, diff_opts) + vim.keymap.set("n", "", function() vim.api.nvim_set_current_win(state.list_win) end, diff_opts) + vim.keymap.set("n", "a", M.approve_current, diff_opts) + vim.keymap.set("n", "r", M.reject_current, diff_opts) + vim.keymap.set("n", "A", M.approve_all, diff_opts) + vim.keymap.set("n", "q", M.close, diff_opts) + vim.keymap.set("n", "", M.close, diff_opts) + + state.is_open = true + state.current_index = 1 + + -- Initial render + update_file_list() + update_diff_view() + + -- Focus list window + vim.api.nvim_set_current_win(state.list_win) +end + +--- Close the diff review UI +function M.close() + if not state.is_open then + return + end + + -- Close the tab (which closes both windows) + pcall(vim.cmd, "tabclose") + + state.list_buf = nil + state.list_win = nil + state.diff_buf = nil + state.diff_win = nil + state.is_open = false +end + +--- Check if review UI is open +---@return boolean +function M.is_open() + return state.is_open +end + +return M diff --git a/lua/codetyper/agent/executor.lua b/lua/codetyper/agent/executor.lua index 08f278e..32e8ef7 100644 --- a/lua/codetyper/agent/executor.lua +++ b/lua/codetyper/agent/executor.lua @@ -4,6 +4,7 @@ local M = {} local utils = require("codetyper.utils") +local logs = require("codetyper.agent.logs") ---@class ExecutionResult ---@field success boolean Whether the execution succeeded @@ -11,6 +12,72 @@ local utils = require("codetyper.utils") ---@field requires_approval boolean Whether user approval is needed ---@field diff_data? DiffData Data for diff preview (if requires_approval) +--- Open a file in a buffer (in a non-agent window) +---@param path string File path to open +---@param jump_to_line? number Optional line number to jump to +local function open_file_in_buffer(path, jump_to_line) + if not path or path == "" then + return + end + + -- Check if file exists + if vim.fn.filereadable(path) ~= 1 then + return + end + + vim.schedule(function() + -- Find a suitable window (not the agent UI windows) + local target_win = nil + local agent_ui_ok, agent_ui = pcall(require, "codetyper.agent.ui") + + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + local buftype = vim.bo[buf].buftype + + -- Skip special buffers (agent UI, nofile, etc.) + if buftype == "" or buftype == "acwrite" then + -- Check if this is not an agent UI window + local is_agent_win = false + if agent_ui_ok and agent_ui.is_open() then + -- Skip agent windows by checking if it's one of our special buffers + local bufname = vim.api.nvim_buf_get_name(buf) + if bufname == "" then + -- Could be agent buffer, check by buffer option + is_agent_win = vim.bo[buf].buftype == "nofile" + end + end + + if not is_agent_win then + target_win = win + break + end + end + end + + -- If no suitable window found, create a new split + if not target_win then + -- Get the rightmost non-agent window or create one + vim.cmd("rightbelow vsplit") + target_win = vim.api.nvim_get_current_win() + end + + -- Open the file in the target window + vim.api.nvim_set_current_win(target_win) + vim.cmd("edit " .. vim.fn.fnameescape(path)) + + -- Jump to line if specified + if jump_to_line and jump_to_line > 0 then + local line_count = vim.api.nvim_buf_line_count(0) + local target_line = math.min(jump_to_line, line_count) + vim.api.nvim_win_set_cursor(target_win, { target_line, 0 }) + vim.cmd("normal! zz") + end + end) +end + +--- Expose open_file_in_buffer for external use +M.open_file_in_buffer = open_file_in_buffer + ---@class DiffData ---@field path string File path ---@field original string Original content @@ -50,15 +117,28 @@ end ---@param callback fun(result: ExecutionResult) function M.handle_read_file(params, callback) local path = M.resolve_path(params.path) + + -- Log the read operation in Claude Code style + local relative_path = vim.fn.fnamemodify(path, ":~:.") + logs.read(relative_path) + local content = utils.read_file(path) if content then + -- Log how many lines were read + local lines = vim.split(content, "\n", { plain = true }) + logs.add({ type = "result", message = string.format(" ⎿ Read %d lines", #lines) }) + + -- Open the file in a buffer so user can see it + open_file_in_buffer(path) + callback({ success = true, result = content, requires_approval = false, }) else + logs.add({ type = "error", message = " ⎿ File not found" }) callback({ success = false, result = "Could not read file: " .. path, @@ -72,9 +152,15 @@ end ---@param callback fun(result: ExecutionResult) function M.handle_edit_file(params, callback) local path = M.resolve_path(params.path) + local relative_path = vim.fn.fnamemodify(path, ":~:.") + + -- Log the edit operation + logs.add({ type = "action", message = string.format("Edit(%s)", relative_path) }) + local original = utils.read_file(path) if not original then + logs.add({ type = "error", message = " ⎿ File not found" }) callback({ success = false, result = "File not found: " .. path, @@ -88,6 +174,7 @@ function M.handle_edit_file(params, callback) local new_content, count = original:gsub(escaped_find, params.replace, 1) if count == 0 then + logs.add({ type = "error", message = " ⎿ Content not found" }) callback({ success = false, result = "Could not find content to replace in: " .. path, @@ -96,6 +183,18 @@ function M.handle_edit_file(params, callback) return end + -- Calculate lines changed + local original_lines = #vim.split(original, "\n", { plain = true }) + local new_lines = #vim.split(new_content, "\n", { plain = true }) + local diff = new_lines - original_lines + if diff > 0 then + logs.add({ type = "result", message = string.format(" ⎿ +%d lines (pending approval)", diff) }) + elseif diff < 0 then + logs.add({ type = "result", message = string.format(" ⎿ %d lines (pending approval)", diff) }) + else + logs.add({ type = "result", message = " ⎿ Modified (pending approval)" }) + end + -- Requires user approval - show diff callback({ success = true, @@ -115,9 +214,29 @@ end ---@param callback fun(result: ExecutionResult) function M.handle_write_file(params, callback) local path = M.resolve_path(params.path) + local relative_path = vim.fn.fnamemodify(path, ":~:.") local original = utils.read_file(path) or "" local operation = original == "" and "create" or "overwrite" + -- Log the write operation + if operation == "create" then + logs.add({ type = "action", message = string.format("Write(%s)", relative_path) }) + local new_lines = #vim.split(params.content, "\n", { plain = true }) + logs.add({ type = "result", message = string.format(" ⎿ New file (%d lines, pending approval)", new_lines) }) + else + logs.add({ type = "action", message = string.format("Update(%s)", relative_path) }) + local original_lines = #vim.split(original, "\n", { plain = true }) + local new_lines = #vim.split(params.content, "\n", { plain = true }) + local diff = new_lines - original_lines + if diff > 0 then + logs.add({ type = "result", message = string.format(" ⎿ +%d lines (pending approval)", diff) }) + elseif diff < 0 then + logs.add({ type = "result", message = string.format(" ⎿ %d lines (pending approval)", diff) }) + else + logs.add({ type = "result", message = " ⎿ Modified (pending approval)" }) + end + end + -- Ensure parent directory exists local dir = vim.fn.fnamemodify(path, ":h") if dir ~= "" and dir ~= "." then @@ -143,6 +262,10 @@ end function M.handle_bash(params, callback) local command = params.command + -- Log the bash operation + logs.add({ type = "action", message = string.format("Bash(%s)", command:sub(1, 50) .. (#command > 50 and "..." or "")) }) + logs.add({ type = "result", message = " ⎿ Pending approval" }) + -- Requires user approval first callback({ success = true, @@ -258,7 +381,8 @@ function M.handle_search_files(params, callback) for _, file in ipairs(files) do -- Skip common ignore patterns if not file:match("node_modules") and not file:match("%.git/") then - table.insert(results, file:gsub(search_path .. "/", "")) + local relative = file:gsub(search_path .. "/", "") + table.insert(results, relative) end end end @@ -272,7 +396,8 @@ function M.handle_search_files(params, callback) if handle then for line in handle:lines() do if not line:match("node_modules") and not line:match("%.git/") then - table.insert(grep_results, line:gsub(search_path .. "/", "")) + local relative = line:gsub(search_path .. "/", "") + table.insert(grep_results, relative) end end handle:close() @@ -348,7 +473,8 @@ function M.apply_change(diff_data, callback) -- Write file local success = utils.write_file(diff_data.path, diff_data.modified) if success then - -- Reload buffer if it's open + -- Open and/or reload buffer so user can see the changes + open_file_in_buffer(diff_data.path) M.reload_buffer_if_open(diff_data.path) callback({ success = true, diff --git a/lua/codetyper/agent/init.lua b/lua/codetyper/agent/init.lua index bc781e5..1bc001f 100644 --- a/lua/codetyper/agent/init.lua +++ b/lua/codetyper/agent/init.lua @@ -8,6 +8,8 @@ local tools = require("codetyper.agent.tools") local executor = require("codetyper.agent.executor") local parser = require("codetyper.agent.parser") local diff = require("codetyper.agent.diff") +local diff_review = require("codetyper.agent.diff_review") +local resume = require("codetyper.agent.resume") local utils = require("codetyper.utils") local logs = require("codetyper.agent.logs") @@ -21,8 +23,11 @@ local state = { conversation = {}, pending_tool_results = {}, is_running = false, - max_iterations = 10, + max_iterations = 25, -- Increased for complex tasks (env setup, tests, fixes) current_iteration = 0, + original_prompt = "", -- Store for resume functionality + current_context = nil, -- Store context for resume + current_callbacks = nil, -- Store callbacks for continue } ---@class AgentCallbacks @@ -38,6 +43,8 @@ function M.reset() state.pending_tool_results = {} state.is_running = false state.current_iteration = 0 + -- Clear collected diffs + diff_review.clear() end --- Check if agent is currently running @@ -67,6 +74,9 @@ function M.run(prompt, context, callbacks) state.is_running = true state.current_iteration = 0 + state.original_prompt = prompt + state.current_context = context + state.current_callbacks = callbacks -- Add user message to conversation table.insert(state.conversation, { @@ -91,9 +101,9 @@ function M.agent_loop(context, callbacks) logs.info(string.format("Agent loop iteration %d/%d", state.current_iteration, state.max_iterations)) if state.current_iteration > state.max_iterations then - logs.error("Max iterations reached") - callbacks.on_error("Max iterations reached (" .. state.max_iterations .. ")") - state.is_running = false + logs.info("Max iterations reached, asking user to continue or stop") + -- Ask user if they want to continue + M.prompt_continue(context, callbacks) return end @@ -222,8 +232,20 @@ function M.process_tool_calls(tool_calls, index, context, callbacks) end logs.tool(tool_call.name, "approved", log_msg) - -- Apply the change + -- Apply the change and collect for review executor.apply_change(result.diff_data, function(apply_result) + -- Collect the diff for end-of-session review + if result.diff_data.operation ~= "bash" then + diff_review.add({ + path = result.diff_data.path, + operation = result.diff_data.operation, + original = result.diff_data.original, + modified = result.diff_data.modified, + approved = true, + applied = true, + }) + end + -- Store result for sending back to LLM table.insert(state.pending_tool_results, { tool_use_id = tool_call.id, @@ -280,21 +302,16 @@ function M.continue_with_results(context, callbacks) local codetyper = require("codetyper") local config = codetyper.get_config() - -- Copilot uses Claude-like format for tool results + -- Copilot uses OpenAI format for tool results (role: "tool") if config.llm.provider == "copilot" then - -- Claude-style tool_result blocks - local content = {} + -- OpenAI-style tool messages - each result is a separate message for _, result in ipairs(state.pending_tool_results) do - table.insert(content, { - type = "tool_result", - tool_use_id = result.tool_use_id, + table.insert(state.conversation, { + role = "tool", + tool_call_id = result.tool_use_id, content = result.result, }) end - table.insert(state.conversation, { - role = "user", - content = content, - }) else -- Ollama format: plain text describing results local result_text = "Tool results:\n" @@ -325,4 +342,114 @@ function M.set_max_iterations(max) state.max_iterations = max end +--- Get the count of collected changes +---@return number +function M.get_changes_count() + return diff_review.count() +end + +--- Show the diff review UI for all collected changes +function M.show_diff_review() + diff_review.open() +end + +--- Check if diff review is open +---@return boolean +function M.is_review_open() + return diff_review.is_open() +end + +--- Prompt user to continue or stop at max iterations +---@param context table File context +---@param callbacks AgentCallbacks +function M.prompt_continue(context, callbacks) + vim.schedule(function() + vim.ui.select({ "Continue (25 more iterations)", "Stop and save for later" }, { + prompt = string.format("Agent reached %d iterations. Continue?", state.max_iterations), + }, function(choice) + if choice and choice:match("^Continue") then + -- Reset iteration counter and continue + state.current_iteration = 0 + logs.info("User chose to continue, resetting iteration counter") + M.agent_loop(context, callbacks) + else + -- Save state for later resume + logs.info("User chose to stop, saving state for resume") + resume.save( + state.conversation, + state.pending_tool_results, + state.current_iteration, + state.original_prompt + ) + state.is_running = false + callbacks.on_text("Agent paused. Use /continue to resume later.") + callbacks.on_complete() + end + end) + end) +end + +--- Continue a previously stopped agent session +---@param callbacks AgentCallbacks +---@return boolean Success +function M.continue_session(callbacks) + if state.is_running then + utils.notify("Agent is already running", vim.log.levels.WARN) + return false + end + + local saved = resume.load() + if not saved then + utils.notify("No saved agent session to continue", vim.log.levels.WARN) + return false + end + + logs.info("Resuming agent session") + logs.info(string.format("Loaded %d messages, iteration %d", #saved.conversation, saved.iteration)) + + -- Restore state + state.conversation = saved.conversation + state.pending_tool_results = saved.pending_tool_results or {} + state.current_iteration = 0 -- Reset for fresh iterations + state.original_prompt = saved.original_prompt + state.is_running = true + state.current_callbacks = callbacks + + -- Build context from current state + local llm = require("codetyper.llm") + local context = {} + local current_file = vim.fn.expand("%:p") + if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then + context = llm.build_context(current_file, "agent") + end + state.current_context = context + + -- Clear saved state + resume.clear() + + -- Add continuation message + table.insert(state.conversation, { + role = "user", + content = "Continue where you left off. Complete the remaining tasks.", + }) + + -- Continue the loop + callbacks.on_text("Resuming agent session...") + M.agent_loop(context, callbacks) + + return true +end + +--- Check if there's a saved session to continue +---@return boolean +function M.has_saved_session() + return resume.has_saved_state() +end + +--- Get info about saved session +---@return table|nil +function M.get_saved_session_info() + return resume.get_info() +end + return M diff --git a/lua/codetyper/agent/intent.lua b/lua/codetyper/agent/intent.lua index e3052c8..2795e3f 100644 --- a/lua/codetyper/agent/intent.lua +++ b/lua/codetyper/agent/intent.lua @@ -62,6 +62,11 @@ local intent_patterns = { "bug", "error", "issue", + "update", + "modify", + "change", + "adjust", + "tweak", }, scope_hint = "function", action = "replace", diff --git a/lua/codetyper/agent/linter.lua b/lua/codetyper/agent/linter.lua new file mode 100644 index 0000000..c7dc3c4 --- /dev/null +++ b/lua/codetyper/agent/linter.lua @@ -0,0 +1,431 @@ +---@mod codetyper.agent.linter Linter validation for generated code +---@brief [[ +--- Validates generated code by checking LSP diagnostics after injection. +--- Automatically saves the file and waits for LSP to update before checking. +---@brief ]] + +local M = {} + +--- Configuration +local config = { + -- Auto-save file after code injection + auto_save = true, + -- Delay in ms to wait for LSP diagnostics to update + diagnostic_delay_ms = 500, + -- Severity levels to check (1=Error, 2=Warning, 3=Info, 4=Hint) + min_severity = vim.diagnostic.severity.WARN, + -- Auto-offer to fix lint errors + auto_offer_fix = true, +} + +--- Diagnostic results for tracking +---@type table +local validation_results = {} + +--- Configure linter behavior +---@param opts table Configuration options +function M.configure(opts) + for k, v in pairs(opts) do + if config[k] ~= nil then + config[k] = v + end + end +end + +--- Get current configuration +---@return table +function M.get_config() + return vim.deepcopy(config) +end + +--- Save buffer if modified +---@param bufnr number Buffer number +---@return boolean success +local function save_buffer(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + + -- Skip if buffer is not modified + if not vim.bo[bufnr].modified then + return true + end + + -- Skip if buffer has no name (unsaved file) + local bufname = vim.api.nvim_buf_get_name(bufnr) + if bufname == "" then + return false + end + + -- Save the buffer + local ok, err = pcall(function() + vim.api.nvim_buf_call(bufnr, function() + vim.cmd("silent! write") + end) + end) + + if not ok then + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "warning", + message = "Failed to save buffer: " .. tostring(err), + }) + end) + return false + end + + return true +end + +--- Get LSP diagnostics for a buffer +---@param bufnr number Buffer number +---@param start_line? number Start line (1-indexed) +---@param end_line? number End line (1-indexed) +---@return table[] diagnostics List of diagnostics +function M.get_diagnostics(bufnr, start_line, end_line) + if not vim.api.nvim_buf_is_valid(bufnr) then + return {} + end + + local all_diagnostics = vim.diagnostic.get(bufnr) + local filtered = {} + + for _, diag in ipairs(all_diagnostics) do + -- Filter by severity + if diag.severity <= config.min_severity then + -- Filter by line range if specified + if start_line and end_line then + local diag_line = diag.lnum + 1 -- Convert to 1-indexed + if diag_line >= start_line and diag_line <= end_line then + table.insert(filtered, diag) + end + else + table.insert(filtered, diag) + end + end + end + + return filtered +end + +--- Format a diagnostic for display +---@param diag table Diagnostic object +---@return string +local function format_diagnostic(diag) + local severity_names = { + [vim.diagnostic.severity.ERROR] = "ERROR", + [vim.diagnostic.severity.WARN] = "WARN", + [vim.diagnostic.severity.INFO] = "INFO", + [vim.diagnostic.severity.HINT] = "HINT", + } + local severity = severity_names[diag.severity] or "UNKNOWN" + local line = diag.lnum + 1 + local source = diag.source or "lsp" + return string.format("[%s] Line %d (%s): %s", severity, line, source, diag.message) +end + +--- Check if there are errors in generated code region +---@param bufnr number Buffer number +---@param start_line number Start line (1-indexed) +---@param end_line number End line (1-indexed) +---@return table result {has_errors, has_warnings, diagnostics, summary} +function M.check_region(bufnr, start_line, end_line) + local diagnostics = M.get_diagnostics(bufnr, start_line, end_line) + + local errors = 0 + local warnings = 0 + + for _, diag in ipairs(diagnostics) do + if diag.severity == vim.diagnostic.severity.ERROR then + errors = errors + 1 + elseif diag.severity == vim.diagnostic.severity.WARN then + warnings = warnings + 1 + end + end + + return { + has_errors = errors > 0, + has_warnings = warnings > 0, + error_count = errors, + warning_count = warnings, + diagnostics = diagnostics, + summary = string.format("%d error(s), %d warning(s)", errors, warnings), + } +end + +--- Validate code after injection and report issues +---@param bufnr number Buffer number +---@param start_line? number Start line of injected code (1-indexed) +---@param end_line? number End line of injected code (1-indexed) +---@param callback? function Callback with (result) when validation completes +function M.validate_after_injection(bufnr, start_line, end_line, callback) + -- Save the file first + if config.auto_save then + save_buffer(bufnr) + end + + -- Wait for LSP to process changes + vim.defer_fn(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + if callback then callback(nil) end + return + end + + local result + if start_line and end_line then + result = M.check_region(bufnr, start_line, end_line) + else + -- Check entire buffer + local line_count = vim.api.nvim_buf_line_count(bufnr) + result = M.check_region(bufnr, 1, line_count) + end + + -- Store result for this buffer + validation_results[bufnr] = { + timestamp = os.time(), + result = result, + start_line = start_line, + end_line = end_line, + } + + -- Log results + pcall(function() + local logs = require("codetyper.agent.logs") + if result.has_errors then + logs.add({ + type = "error", + message = string.format("Linter found issues: %s", result.summary), + }) + -- Log individual errors + for _, diag in ipairs(result.diagnostics) do + if diag.severity == vim.diagnostic.severity.ERROR then + logs.add({ + type = "error", + message = format_diagnostic(diag), + }) + end + end + elseif result.has_warnings then + logs.add({ + type = "warning", + message = string.format("Linter warnings: %s", result.summary), + }) + else + logs.add({ + type = "success", + message = "Linter check passed - no errors or warnings", + }) + end + end) + + -- Notify user + if result.has_errors then + vim.notify( + string.format("Generated code has lint errors: %s", result.summary), + vim.log.levels.ERROR + ) + + -- Offer to fix if configured + if config.auto_offer_fix and #result.diagnostics > 0 then + M.offer_fix(bufnr, result) + end + elseif result.has_warnings then + vim.notify( + string.format("Generated code has warnings: %s", result.summary), + vim.log.levels.WARN + ) + end + + if callback then + callback(result) + end + end, config.diagnostic_delay_ms) +end + +--- Offer to fix lint errors using AI +---@param bufnr number Buffer number +---@param result table Validation result +function M.offer_fix(bufnr, result) + if not result.has_errors and not result.has_warnings then + return + end + + -- Build error summary for prompt + local error_messages = {} + for _, diag in ipairs(result.diagnostics) do + table.insert(error_messages, format_diagnostic(diag)) + end + + vim.ui.select( + { "Yes - Auto-fix with AI", "No - I'll fix manually", "Show errors in quickfix" }, + { + prompt = string.format("Found %d issue(s). Would you like AI to fix them?", #result.diagnostics), + }, + function(choice) + if not choice then return end + + if choice:match("^Yes") then + M.request_ai_fix(bufnr, result) + elseif choice:match("quickfix") then + M.show_in_quickfix(bufnr, result) + end + end + ) +end + +--- Show lint errors in quickfix list +---@param bufnr number Buffer number +---@param result table Validation result +function M.show_in_quickfix(bufnr, result) + local qf_items = {} + local bufname = vim.api.nvim_buf_get_name(bufnr) + + for _, diag in ipairs(result.diagnostics) do + table.insert(qf_items, { + bufnr = bufnr, + filename = bufname, + lnum = diag.lnum + 1, + col = diag.col + 1, + text = diag.message, + type = diag.severity == vim.diagnostic.severity.ERROR and "E" or "W", + }) + end + + vim.fn.setqflist(qf_items, "r") + vim.cmd("copen") +end + +--- Request AI to fix lint errors +---@param bufnr number Buffer number +---@param result table Validation result +function M.request_ai_fix(bufnr, result) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local filepath = vim.api.nvim_buf_get_name(bufnr) + + -- Build fix prompt + local error_list = {} + for _, diag in ipairs(result.diagnostics) do + table.insert(error_list, format_diagnostic(diag)) + end + + -- Get the affected code region + local start_line = result.diagnostics[1] and (result.diagnostics[1].lnum + 1) or 1 + local end_line = start_line + for _, diag in ipairs(result.diagnostics) do + local line = diag.lnum + 1 + if line < start_line then start_line = line end + if line > end_line then end_line = line end + end + + -- Expand range by a few lines for context + start_line = math.max(1, start_line - 5) + end_line = math.min(vim.api.nvim_buf_line_count(bufnr), end_line + 5) + + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + local code_context = table.concat(lines, "\n") + + -- Create fix prompt using inline tag + local fix_prompt = string.format( + "Fix the following linter errors in this code:\n\nERRORS:\n%s\n\nCODE (lines %d-%d):\n%s", + table.concat(error_list, "\n"), + start_line, + end_line, + code_context + ) + + -- Queue the fix through the scheduler + local scheduler = require("codetyper.agent.scheduler") + local queue = require("codetyper.agent.queue") + local patch_mod = require("codetyper.agent.patch") + + -- Ensure scheduler is running + if not scheduler.status().running then + scheduler.start() + end + + -- Take snapshot + local snapshot = patch_mod.snapshot_buffer(bufnr, { + start_line = start_line, + end_line = end_line, + }) + + -- Enqueue fix request + queue.enqueue({ + id = queue.generate_id(), + bufnr = bufnr, + range = { start_line = start_line, end_line = end_line }, + timestamp = os.clock(), + changedtick = snapshot.changedtick, + content_hash = snapshot.content_hash, + prompt_content = fix_prompt, + target_path = filepath, + priority = 1, -- High priority for fixes + status = "pending", + attempt_count = 0, + intent = { + type = "fix", + action = "replace", + confidence = 0.9, + }, + scope_range = { start_line = start_line, end_line = end_line }, + source = "linter_fix", + }) + + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = "Queued AI fix request for lint errors", + }) + end) + + vim.notify("Queued AI fix request for lint errors", vim.log.levels.INFO) +end + +--- Get last validation result for a buffer +---@param bufnr number Buffer number +---@return table|nil result +function M.get_last_result(bufnr) + return validation_results[bufnr] +end + +--- Clear validation results for a buffer +---@param bufnr number Buffer number +function M.clear_result(bufnr) + validation_results[bufnr] = nil +end + +--- Check if buffer has any lint errors currently +---@param bufnr number Buffer number +---@return boolean has_errors +function M.has_errors(bufnr) + local diagnostics = vim.diagnostic.get(bufnr, { + severity = vim.diagnostic.severity.ERROR, + }) + return #diagnostics > 0 +end + +--- Check if buffer has any lint warnings currently +---@param bufnr number Buffer number +---@return boolean has_warnings +function M.has_warnings(bufnr) + local diagnostics = vim.diagnostic.get(bufnr, { + severity = { min = vim.diagnostic.severity.WARN }, + }) + return #diagnostics > 0 +end + +--- Validate all buffers with recent changes +function M.validate_all_changed() + for bufnr, data in pairs(validation_results) do + if vim.api.nvim_buf_is_valid(bufnr) then + M.validate_after_injection(bufnr, data.start_line, data.end_line) + end + end +end + +return M diff --git a/lua/codetyper/agent/logs.lua b/lua/codetyper/agent/logs.lua index 3741479..3637549 100644 --- a/lua/codetyper/agent/logs.lua +++ b/lua/codetyper/agent/logs.lua @@ -165,10 +165,83 @@ function M.add(entry) M.log(entry.type or "info", entry.message or "", entry.data) end ---- Log thinking/reasoning step +--- Log thinking/reasoning step (Claude Code style) ---@param step string Description of what's happening function M.thinking(step) - M.log("debug", "> " .. step) + M.log("thinking", step) +end + +--- Log a reasoning/explanation message (shown prominently) +---@param message string The reasoning message +function M.reason(message) + M.log("reason", message) +end + +--- Log file read operation +---@param filepath string Path of file being read +---@param lines? number Number of lines read +function M.read(filepath, lines) + local msg = string.format("Read(%s)", vim.fn.fnamemodify(filepath, ":~:.")) + if lines then + msg = msg .. string.format("\n ⎿ Read %d lines", lines) + end + M.log("action", msg) +end + +--- Log explore/search operation +---@param description string What we're exploring +function M.explore(description) + M.log("action", string.format("Explore(%s)", description)) +end + +--- Log explore done +---@param tool_uses number Number of tool uses +---@param tokens number Tokens used +---@param duration number Duration in seconds +function M.explore_done(tool_uses, tokens, duration) + M.log("result", string.format(" ⎿ Done (%d tool uses · %.1fk tokens · %.1fs)", tool_uses, tokens / 1000, duration)) +end + +--- Log update/edit operation +---@param filepath string Path of file being edited +---@param added? number Lines added +---@param removed? number Lines removed +function M.update(filepath, added, removed) + local msg = string.format("Update(%s)", vim.fn.fnamemodify(filepath, ":~:.")) + if added or removed then + local parts = {} + if added and added > 0 then + table.insert(parts, string.format("Added %d lines", added)) + end + if removed and removed > 0 then + table.insert(parts, string.format("Removed %d lines", removed)) + end + if #parts > 0 then + msg = msg .. "\n ⎿ " .. table.concat(parts, ", ") + end + end + M.log("action", msg) +end + +--- Log a task/step that's in progress +---@param task string Task name +---@param status string Status message (optional) +function M.task(task, status) + local msg = task + if status then + msg = msg .. " " .. status + end + M.log("task", msg) +end + +--- Log task completion +---@param next_task? string Next task (optional) +function M.task_done(next_task) + local msg = " ⎿ Done" + if next_task then + msg = msg .. "\n✶ " .. next_task + end + M.log("result", msg) end --- Register a listener for new log entries @@ -223,6 +296,27 @@ end ---@param entry LogEntry ---@return string function M.format_entry(entry) + -- Claude Code style formatting for thinking/action entries + local thinking_types = { "thinking", "reason", "action", "task", "result" } + local is_thinking = vim.tbl_contains(thinking_types, entry.level) + + if is_thinking then + local prefix = ({ + thinking = "⏺", + reason = "⏺", + action = "⏺", + task = "✶", + result = "", + })[entry.level] or "⏺" + + if prefix ~= "" then + return prefix .. " " .. entry.message + else + return entry.message + end + end + + -- Traditional log format for other types local level_prefix = ({ info = "i", debug = ".", @@ -248,6 +342,60 @@ function M.format_entry(entry) return base end +--- Format entry for display in chat (compact Claude Code style) +---@param entry LogEntry +---@return string|nil Formatted string or nil to skip +function M.format_for_chat(entry) + -- Skip certain log types in chat view + local skip_types = { "debug", "queue", "patch" } + if vim.tbl_contains(skip_types, entry.level) then + return nil + end + + -- Claude Code style formatting + local thinking_types = { "thinking", "reason", "action", "task", "result" } + if vim.tbl_contains(thinking_types, entry.level) then + local prefix = ({ + thinking = "⏺", + reason = "⏺", + action = "⏺", + task = "✶", + result = "", + })[entry.level] or "⏺" + + if prefix ~= "" then + return prefix .. " " .. entry.message + else + return entry.message + end + end + + -- Tool logs + if entry.level == "tool" then + return "⏺ " .. entry.message:gsub("^%[.-%] ", "") + end + + -- Info/success + if entry.level == "info" or entry.level == "success" then + return "⏺ " .. entry.message + end + + -- Errors + if entry.level == "error" then + return "⚠ " .. entry.message + end + + -- Request/response (compact) + if entry.level == "request" then + return "⏺ " .. entry.message + end + if entry.level == "response" then + return " ⎿ " .. entry.message + end + + return nil +end + --- Estimate token count for a string (rough approximation) ---@param text string ---@return number diff --git a/lua/codetyper/agent/loop.lua b/lua/codetyper/agent/loop.lua index cea5832..5e9048c 100644 --- a/lua/codetyper/agent/loop.lua +++ b/lua/codetyper/agent/loop.lua @@ -1,7 +1,7 @@ ---@mod codetyper.agent.loop Agent loop with tool orchestration ---@brief [[ --- Main agent loop that handles multi-turn conversations with tool use. ---- Inspired by avante.nvim's agent_loop pattern. +--- Agent execution loop with tool calling support. ---@brief ]] local M = {} diff --git a/lua/codetyper/agent/patch.lua b/lua/codetyper/agent/patch.lua index 623d59f..a461107 100644 --- a/lua/codetyper/agent/patch.lua +++ b/lua/codetyper/agent/patch.lua @@ -2,7 +2,7 @@ ---@brief [[ --- Manages code patches with buffer snapshots for staleness detection. --- Patches are queued for safe injection when completion popup is not visible. ---- Uses smart injection for intelligent import merging. +--- Uses SEARCH/REPLACE blocks for reliable code editing. ---@brief ]] local M = {} @@ -12,6 +12,24 @@ local function get_inject_module() return require("codetyper.agent.inject") end +--- Lazy load search_replace module +local function get_search_replace_module() + return require("codetyper.agent.search_replace") +end + +--- Lazy load conflict module +local function get_conflict_module() + return require("codetyper.agent.conflict") +end + +--- Configuration for patch behavior +local config = { + -- Use conflict markers instead of direct apply (allows interactive review) + use_conflict_mode = true, + -- Auto-jump to first conflict after applying + auto_jump_to_conflict = true, +} + ---@class BufferSnapshot ---@field bufnr number Buffer number ---@field changedtick number vim.b.changedtick at snapshot time @@ -27,11 +45,13 @@ end ---@field original_snapshot BufferSnapshot Snapshot at event creation ---@field generated_code string Code to inject ---@field injection_range {start_line: number, end_line: number}|nil ----@field injection_strategy string "append"|"replace"|"insert" +---@field injection_strategy string "append"|"replace"|"insert"|"search_replace" ---@field confidence number Confidence score (0.0-1.0) ---@field status string "pending"|"applied"|"stale"|"rejected" ---@field created_at number Timestamp ---@field applied_at number|nil When applied +---@field use_search_replace boolean Whether to use SEARCH/REPLACE block parsing +---@field search_replace_blocks table[]|nil Parsed SEARCH/REPLACE blocks --- Patch storage ---@type PatchCandidate[] @@ -194,6 +214,10 @@ function M.create_from_event(event, generated_code, confidence, strategy) end end + -- Detect if this is an inline prompt (source == target, not a .coder. file) + local is_inline = (source_bufnr == target_bufnr) or + (event.target_path and not event.target_path:match("%.coder%.")) + -- Take snapshot of the scope range in target buffer (for staleness detection) local snapshot_range = event.scope_range or event.range local snapshot = M.snapshot_buffer( @@ -201,22 +225,67 @@ function M.create_from_event(event, generated_code, confidence, strategy) snapshot_range ) + -- Check if the response contains SEARCH/REPLACE blocks + local search_replace = get_search_replace_module() + local sr_blocks = search_replace.parse_blocks(generated_code) + local use_search_replace = #sr_blocks > 0 + -- Determine injection strategy and range based on intent local injection_strategy = strategy local injection_range = nil - if not injection_strategy and event.intent then + -- If we have SEARCH/REPLACE blocks, use that strategy + if use_search_replace then + injection_strategy = "search_replace" + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = string.format("Using SEARCH/REPLACE mode with %d block(s)", #sr_blocks), + }) + end) + elseif not injection_strategy and event.intent then local intent_mod = require("codetyper.agent.intent") if intent_mod.is_replacement(event.intent) then injection_strategy = "replace" - -- Use scope range for replacement - if event.scope_range then + + -- INLINE PROMPTS: Always use tag range + -- The LLM is told specifically to replace the tagged region + if is_inline and event.range then + injection_range = { + start_line = event.range.start_line, + end_line = event.range.end_line, + } + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = string.format("Inline prompt: will replace tag region (lines %d-%d)", + event.range.start_line, event.range.end_line), + }) + end) + -- CODER FILES: Use scope range for replacement + elseif event.scope_range then injection_range = event.scope_range + else + -- Fallback: no scope found (treesitter didn't find function) + -- Use tag range - the generated code will replace the tag region + injection_range = { + start_line = event.range.start_line, + end_line = event.range.end_line, + } + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "warning", + message = "No scope found, using tag range as fallback", + }) + end) end elseif event.intent.action == "insert" then injection_strategy = "insert" - -- Insert at prompt location - injection_range = { start_line = event.range.start_line, end_line = event.range.start_line } + -- Insert at prompt location (use full tag range) + injection_range = { start_line = event.range.start_line, end_line = event.range.end_line } elseif event.intent.action == "append" then injection_strategy = "append" -- Will append to end of file @@ -244,6 +313,11 @@ function M.create_from_event(event, generated_code, confidence, strategy) scope = event.scope, -- Store the prompt tag range so we can delete it after applying prompt_tag_range = event.range, + -- Mark if this is an inline prompt (tags in source file, not coder file) + is_inline_prompt = is_inline, + -- SEARCH/REPLACE support + use_search_replace = use_search_replace, + search_replace_blocks = use_search_replace and sr_blocks or nil, } end @@ -464,13 +538,15 @@ function M.apply(patch) -- Prepare code lines local code_lines = vim.split(patch.generated_code, "\n", { plain = true }) - -- FIRST: Remove the prompt tags from the SOURCE buffer (coder file), not target - -- The tags are in the coder file where the user wrote the prompt - -- Code goes to target file, tags get removed from source file + -- 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 + local is_inline_prompt = patch.is_inline_prompt or (source_bufnr == target_bufnr) local tags_removed = 0 - if source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then + -- For CODER FILES (source != target): Remove tags from source, inject into target + -- For INLINE PROMPTS (source == target): Include tag range in injection, no separate removal + if not is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then tags_removed = remove_prompt_tags(source_bufnr) pcall(function() @@ -490,6 +566,76 @@ function M.apply(patch) -- Get filetype for smart injection local filetype = vim.fn.fnamemodify(patch.target_path or "", ":e") + -- SEARCH/REPLACE MODE: Use fuzzy matching to find and replace text + if patch.use_search_replace and patch.search_replace_blocks and #patch.search_replace_blocks > 0 then + local search_replace = get_search_replace_module() + + -- Remove the /@ @/ tags first (they shouldn't be in the file anymore) + if is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then + tags_removed = remove_prompt_tags(source_bufnr) + if tags_removed > 0 then + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = string.format("Removed %d prompt tag(s)", tags_removed), + }) + end) + end + end + + -- Apply SEARCH/REPLACE blocks + local success, err = search_replace.apply_to_buffer(target_bufnr, patch.search_replace_blocks) + + if success then + M.mark_applied(patch.id) + + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "success", + message = string.format("Patch %s applied via SEARCH/REPLACE (%d block(s))", + patch.id, #patch.search_replace_blocks), + data = { + target_path = patch.target_path, + blocks_applied = #patch.search_replace_blocks, + }, + }) + end) + + -- Learn from successful code generation + pcall(function() + local brain = require("codetyper.brain") + if brain.is_initialized() then + local intent_type = patch.intent and patch.intent.type or "unknown" + brain.learn({ + type = "code_completion", + file = patch.target_path, + timestamp = os.time(), + data = { + intent = intent_type, + method = "search_replace", + language = filetype, + confidence = patch.confidence or 0.5, + }, + }) + end + end) + + return true, nil + else + -- SEARCH/REPLACE failed, log the error + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "warning", + message = string.format("SEARCH/REPLACE failed: %s. Falling back to line-based injection.", err or "unknown"), + }) + end) + -- Fall through to line-based injection as fallback + end + end + -- Use smart injection module for intelligent import handling local inject = get_inject_module() local inject_result = nil @@ -508,10 +654,10 @@ function M.apply(patch) local start_line = patch.injection_range.start_line local end_line = patch.injection_range.end_line - -- Adjust for tag removal - find the new range by searching for the scope - -- After removing tags, line numbers may have shifted - if patch.scope and patch.scope.type then - -- Try to find the scope using treesitter if available + -- 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 + -- For coder files, tags were already removed, so we may need to find the scope again local found_range = nil pcall(function() local parsers = require("nvim-treesitter.parsers") @@ -562,7 +708,31 @@ function M.apply(patch) inject_opts.range = { start_line = start_line, end_line = end_line } elseif patch.injection_strategy == "insert" and patch.injection_range then - inject_opts.range = { start_line = patch.injection_range.start_line } + -- For inline prompts with "insert" strategy, replace the TAG RANGE + -- (the tag itself gets replaced with the new code) + if is_inline_prompt and patch.prompt_tag_range then + inject_opts.range = { + start_line = patch.prompt_tag_range.start_line, + end_line = patch.prompt_tag_range.end_line + } + -- Switch to replace strategy for the tag range + inject_opts.strategy = "replace" + else + inject_opts.range = { start_line = patch.injection_range.start_line } + end + end + + -- Log inline prompt handling + if is_inline_prompt then + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "info", + message = string.format("Inline prompt: replacing lines %d-%d", + inject_opts.range and inject_opts.range.start_line or 0, + inject_opts.range and inject_opts.range.end_line or 0), + }) + end) end -- Use smart injection - handles imports automatically @@ -729,4 +899,202 @@ function M.clear() patches = {} end +--- Configure patch behavior +---@param opts table Configuration options +--- - use_conflict_mode: boolean Use conflict markers instead of direct apply +--- - auto_jump_to_conflict: boolean Auto-jump to first conflict after applying +function M.configure(opts) + if opts.use_conflict_mode ~= nil then + config.use_conflict_mode = opts.use_conflict_mode + end + if opts.auto_jump_to_conflict ~= nil then + config.auto_jump_to_conflict = opts.auto_jump_to_conflict + end +end + +--- Get current configuration +---@return table +function M.get_config() + return vim.deepcopy(config) +end + +--- Check if conflict mode is enabled +---@return boolean +function M.is_conflict_mode() + return config.use_conflict_mode +end + +--- Apply a patch using conflict markers for interactive review +--- Instead of directly replacing code, inserts git-style conflict markers +---@param patch PatchCandidate +---@return boolean success +---@return string|nil error +function M.apply_with_conflict(patch) + -- Check if safe to modify (not in insert mode) + if not is_safe_to_modify() then + return false, "user_typing" + end + + -- Check staleness first + local is_stale, stale_reason = M.is_stale(patch) + if is_stale then + M.mark_stale(patch.id, stale_reason) + return false, "patch_stale: " .. (stale_reason or "unknown") + end + + -- Ensure target buffer is valid + local target_bufnr = patch.target_bufnr + if target_bufnr == -1 or not vim.api.nvim_buf_is_valid(target_bufnr) then + target_bufnr = vim.fn.bufadd(patch.target_path) + if target_bufnr == 0 then + M.mark_rejected(patch.id, "buffer_not_found") + return false, "target buffer not found" + end + vim.fn.bufload(target_bufnr) + patch.target_bufnr = target_bufnr + end + + local conflict = get_conflict_module() + local source_bufnr = patch.source_bufnr + local is_inline_prompt = patch.is_inline_prompt or (source_bufnr == target_bufnr) + + -- Remove tags from coder files + if not is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then + remove_prompt_tags(source_bufnr) + end + + -- For SEARCH/REPLACE blocks, convert each block to a conflict + if patch.use_search_replace and patch.search_replace_blocks and #patch.search_replace_blocks > 0 then + local search_replace = get_search_replace_module() + local content = table.concat(vim.api.nvim_buf_get_lines(target_bufnr, 0, -1, false), "\n") + local applied_count = 0 + + -- Sort blocks by position (bottom to top) to maintain line numbers + local sorted_blocks = {} + for _, block in ipairs(patch.search_replace_blocks) do + local match = search_replace.find_match(content, block.search) + if match then + block._match = match + table.insert(sorted_blocks, block) + end + end + table.sort(sorted_blocks, function(a, b) + return (a._match and a._match.start_line or 0) > (b._match and b._match.start_line or 0) + end) + + -- Apply each block as a conflict + for _, block in ipairs(sorted_blocks) do + local match = block._match + if match then + local new_lines = vim.split(block.replace, "\n", { plain = true }) + conflict.insert_conflict( + target_bufnr, + match.start_line, + match.end_line, + new_lines, + "AI SUGGESTION" + ) + applied_count = applied_count + 1 + -- Re-read content for next match (line numbers changed) + content = table.concat(vim.api.nvim_buf_get_lines(target_bufnr, 0, -1, false), "\n") + end + end + + if applied_count > 0 then + -- Remove tags for inline prompts after inserting conflicts + if is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then + remove_prompt_tags(source_bufnr) + end + + -- Process conflicts (highlight, keymaps) and show menu + conflict.process_and_show_menu(target_bufnr) + + M.mark_applied(patch.id) + + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "success", + message = string.format( + "Created %d conflict(s) for review - use co/ct/cb/cn to resolve", + applied_count + ), + }) + end) + + return true, nil + end + end + + -- Fallback: Use injection range if available + if patch.injection_range then + local start_line = patch.injection_range.start_line + local end_line = patch.injection_range.end_line + local new_lines = vim.split(patch.generated_code, "\n", { plain = true }) + + -- Remove tags for inline prompts + if is_inline_prompt and source_bufnr and vim.api.nvim_buf_is_valid(source_bufnr) then + remove_prompt_tags(source_bufnr) + end + + -- Insert conflict markers + conflict.insert_conflict(target_bufnr, start_line, end_line, new_lines, "AI SUGGESTION") + + -- Process conflicts (highlight, keymaps) and show menu + conflict.process_and_show_menu(target_bufnr) + + M.mark_applied(patch.id) + + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "success", + message = "Created conflict for review - use co/ct/cb/cn to resolve", + }) + end) + + return true, nil + end + + -- No suitable range found, fall back to direct apply + return M.apply(patch) +end + +--- Smart apply - uses conflict mode if enabled, otherwise direct apply +---@param patch PatchCandidate +---@return boolean success +---@return string|nil error +function M.smart_apply(patch) + if config.use_conflict_mode then + return M.apply_with_conflict(patch) + else + return M.apply(patch) + end +end + +--- Flush all pending patches using smart apply +---@return number applied_count +---@return number stale_count +---@return number deferred_count +function M.flush_pending_smart() + local applied = 0 + local stale = 0 + local deferred = 0 + + for _, p in ipairs(patches) do + if p.status == "pending" then + local success, err = M.smart_apply(p) + if success then + applied = applied + 1 + elseif err == "user_typing" then + deferred = deferred + 1 + else + stale = stale + 1 + end + end + end + + return applied, stale, deferred +end + return M diff --git a/lua/codetyper/agent/resume.lua b/lua/codetyper/agent/resume.lua new file mode 100644 index 0000000..ef44dd5 --- /dev/null +++ b/lua/codetyper/agent/resume.lua @@ -0,0 +1,155 @@ +---@mod codetyper.agent.resume Resume context for agent sessions +--- +--- Saves and loads agent state to allow continuing long-running tasks. + +local M = {} + +local utils = require("codetyper.utils") + +--- Get the resume context directory +---@return string|nil +local function get_resume_dir() + local root = utils.get_project_root() or vim.fn.getcwd() + return root .. "/.coder/tmp" +end + +--- Get the resume context file path +---@return string|nil +local function get_resume_path() + local dir = get_resume_dir() + if not dir then + return nil + end + return dir .. "/agent_resume.json" +end + +--- Ensure the resume directory exists +---@return boolean +local function ensure_resume_dir() + local dir = get_resume_dir() + if not dir then + return false + end + return utils.ensure_dir(dir) +end + +---@class ResumeContext +---@field conversation table[] Message history +---@field pending_tool_results table[] Pending results +---@field iteration number Current iteration count +---@field original_prompt string Original user prompt +---@field timestamp number When saved +---@field project_root string Project root path + +--- Save the current agent state for resuming later +---@param conversation table[] Conversation history +---@param pending_results table[] Pending tool results +---@param iteration number Current iteration +---@param original_prompt string Original prompt +---@return boolean Success +function M.save(conversation, pending_results, iteration, original_prompt) + if not ensure_resume_dir() then + return false + end + + local path = get_resume_path() + if not path then + return false + end + + local context = { + conversation = conversation, + pending_tool_results = pending_results, + iteration = iteration, + original_prompt = original_prompt, + timestamp = os.time(), + project_root = utils.get_project_root() or vim.fn.getcwd(), + } + + local ok, json = pcall(vim.json.encode, context) + if not ok then + utils.notify("Failed to encode resume context", vim.log.levels.ERROR) + return false + end + + local success = utils.write_file(path, json) + if success then + utils.notify("Agent state saved. Use /continue to resume.", vim.log.levels.INFO) + end + return success +end + +--- Load saved agent state +---@return ResumeContext|nil +function M.load() + local path = get_resume_path() + if not path then + return nil + end + + local content = utils.read_file(path) + if not content or content == "" then + return nil + end + + local ok, context = pcall(vim.json.decode, content) + if not ok or not context then + return nil + end + + return context +end + +--- Check if there's a saved resume context +---@return boolean +function M.has_saved_state() + local path = get_resume_path() + if not path then + return false + end + return vim.fn.filereadable(path) == 1 +end + +--- Get info about saved state (for display) +---@return table|nil +function M.get_info() + local context = M.load() + if not context then + return nil + end + + local age_seconds = os.time() - (context.timestamp or 0) + local age_str + if age_seconds < 60 then + age_str = age_seconds .. " seconds ago" + elseif age_seconds < 3600 then + age_str = math.floor(age_seconds / 60) .. " minutes ago" + else + age_str = math.floor(age_seconds / 3600) .. " hours ago" + end + + return { + prompt = context.original_prompt, + iteration = context.iteration, + messages = #context.conversation, + saved_at = age_str, + project = context.project_root, + } +end + +--- Clear saved resume context +---@return boolean +function M.clear() + local path = get_resume_path() + if not path then + return false + end + + if vim.fn.filereadable(path) == 1 then + os.remove(path) + return true + end + return false +end + +return M diff --git a/lua/codetyper/agent/scheduler.lua b/lua/codetyper/agent/scheduler.lua index c98f4c3..3f01ca6 100644 --- a/lua/codetyper/agent/scheduler.lua +++ b/lua/codetyper/agent/scheduler.lua @@ -124,7 +124,7 @@ end --- Retry event with additional context ---@param original_event table Original prompt event ---@param additional_context string Additional context from user -local function retry_with_context(original_event, additional_context) +local function retry_with_context(original_event, additional_context, attached_files) -- Create new prompt content combining original + additional local combined_prompt = string.format( "%s\n\nAdditional context:\n%s", @@ -138,6 +138,10 @@ local function retry_with_context(original_event, additional_context) new_event.prompt_content = combined_prompt new_event.attempt_count = 0 new_event.status = nil + -- Preserve any attached files provided by the context modal + if attached_files and #attached_files > 0 then + new_event.attached_files = attached_files + end -- Log the retry pcall(function() @@ -152,6 +156,79 @@ local function retry_with_context(original_event, additional_context) queue.enqueue(new_event) end +--- Try to parse requested file paths from an LLM response asking for more context +---@param response string +---@return string[] list of resolved full paths +local function parse_requested_files(response) + if not response or response == "" then + return {} + end + + local cwd = vim.fn.getcwd() + local results = {} + local seen = {} + + -- Heuristics: capture backticked paths, lines starting with - or *, or raw paths with slashes and extension + for path in response:gmatch("`([%w%._%-%/]+%.[%w_]+)`") do + if not seen[path] then + table.insert(results, path) + seen[path] = true + end + end + + for path in response:gmatch("([%w%._%-%/]+%.[%w_]+)") do + if not seen[path] then + -- Filter out common English words that match the pattern + if not path:match("^[Ii]$") and not path:match("^[Tt]his$") then + table.insert(results, path) + seen[path] = true + end + end + end + + -- Also capture list items like '- src/foo.lua' + for line in response:gmatch("[^\\n]+") do + local m = line:match("^%s*[-*]%s*([%w%._%-%/]+%.[%w_]+)%s*$") + if m and not seen[m] then + table.insert(results, m) + seen[m] = true + end + end + + -- Resolve each candidate to a full path by checking cwd and globbing + local resolved = {} + for _, p in ipairs(results) do + local candidate = p + local full = nil + + -- If absolute or already rooted + if candidate:sub(1,1) == "/" and vim.fn.filereadable(candidate) == 1 then + full = candidate + else + -- Try relative to cwd + local try1 = cwd .. "/" .. candidate + if vim.fn.filereadable(try1) == 1 then + full = try1 + else + -- Try globbing for filename anywhere in project + local basename = candidate + -- If candidate contains slashes, try the tail + local tail = candidate:match("[^/]+$") or candidate + local matches = vim.fn.globpath(cwd, "**/" .. tail, false, true) + if matches and #matches > 0 then + full = matches[1] + end + end + end + + if full and vim.fn.filereadable(full) == 1 then + table.insert(resolved, full) + end + end + + return resolved +end + --- Process worker result and decide next action ---@param event table PromptEvent ---@param result table WorkerResult @@ -166,7 +243,87 @@ local function handle_worker_result(event, result) }) end) - -- Open the context modal + -- Try to auto-attach any files the LLM specifically requested in its response + local requested = parse_requested_files(result.response or "") + + -- Detect suggested shell commands the LLM may want executed (e.g., "run ls -la", "please run git status") + local function detect_suggested_commands(response) + if not response then + return {} + end + local cmds = {} + -- capture backticked commands: `ls -la` + for c in response:gmatch("`([^`]+)`") do + if #c > 1 and not c:match("%-%-help") then + table.insert(cmds, { label = c, cmd = c }) + end + end + -- capture phrases like: run ls -la or run `ls -la` + for m in response:gmatch("[Rr]un%s+([%w%p%s%-_/]+)") do + local cand = m:gsub("^%s+",""):gsub("%s+$","") + if cand and #cand > 1 then + -- ignore long sentences; keep first line or command-like substring + local line = cand:match("[^\n]+") or cand + line = line:gsub("and then.*","") + line = line:gsub("please.*","") + if not line:match("%a+%s+files") then + table.insert(cmds, { label = line, cmd = line }) + end + end + end + -- dedupe + local seen = {} + local out = {} + for _, v in ipairs(cmds) do + if v.cmd and not seen[v.cmd] then + seen[v.cmd] = true + table.insert(out, v) + end + end + return out + end + + local suggested_cmds = detect_suggested_commands(result.response or "") + if suggested_cmds and #suggested_cmds > 0 then + -- Open modal and show suggested commands for user approval + context_modal.open(result.original_event or event, result.response or "", retry_with_context, suggested_cmds) + queue.update_status(event.id, "needs_context", { response = result.response }) + return + end + if requested and #requested > 0 then + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ type = "info", message = string.format("Auto-attaching %d requested file(s)", #requested) }) + end) + + -- Build attached_files entries + local attached = event.attached_files or {} + for _, full in ipairs(requested) do + local ok, content = pcall(function() + return table.concat(vim.fn.readfile(full), "\n") + end) + if ok and content then + table.insert(attached, { + path = vim.fn.fnamemodify(full, ":~:."), + full_path = full, + content = content, + }) + end + end + + -- Retry automatically with same prompt but attached files + local new_event = vim.deepcopy(result.original_event or event) + new_event.id = nil + new_event.attached_files = attached + new_event.attempt_count = 0 + new_event.status = nil + queue.enqueue(new_event) + + queue.update_status(event.id, "needs_context", { response = result.response }) + return + end + + -- If no files parsed, open modal for manual context entry context_modal.open(result.original_event or event, result.response or "", retry_with_context) -- Mark original event as needing context (not failed) @@ -321,7 +478,7 @@ function M.schedule_patch_flush() local safe, reason = M.is_safe_to_inject() if safe then waiting_to_flush = false - local applied, stale = patch.flush_pending() + local applied, stale = patch.flush_pending_smart() if applied > 0 or stale > 0 then pcall(function() local logs = require("codetyper.agent.logs") @@ -382,7 +539,7 @@ local function setup_autocmds() callback = function() vim.defer_fn(function() if not M.is_completion_visible() then - patch.flush_pending() + patch.flush_pending_smart() end end, state.config.completion_delay_ms) end, @@ -394,7 +551,7 @@ local function setup_autocmds() group = augroup, callback = function() if not M.is_insert_mode() and not M.is_completion_visible() then - patch.flush_pending() + patch.flush_pending_smart() end end, desc = "Flush pending patches on CursorHold", @@ -563,7 +720,7 @@ end --- Force flush all pending patches (ignores completion check) function M.force_flush() - return patch.flush_pending() + return patch.flush_pending_smart() end --- Update configuration @@ -574,4 +731,33 @@ function M.configure(config) end end +--- Queue a prompt for processing +--- This is a convenience function that creates a proper PromptEvent and enqueues it +---@param opts table Prompt options +--- - bufnr: number Source buffer number +--- - filepath: string Source file path +--- - target_path: string Target file for injection (can be same as filepath) +--- - prompt_content: string The cleaned prompt text +--- - range: {start_line: number, end_line: number} Line range of prompt tag +--- - source: string|nil Source identifier (e.g., "transform_command", "autocmd") +--- - priority: number|nil Priority (1=high, 2=normal, 3=low) default 2 +---@return table The enqueued event +function M.queue_prompt(opts) + -- Build the PromptEvent structure + local event = { + bufnr = opts.bufnr, + filepath = opts.filepath, + target_path = opts.target_path or opts.filepath, + prompt_content = opts.prompt_content, + range = opts.range, + priority = opts.priority or 2, + source = opts.source or "manual", + -- Capture buffer state for staleness detection + changedtick = vim.api.nvim_buf_get_changedtick(opts.bufnr), + } + + -- Enqueue through the queue module + return queue.enqueue(event) +end + return M diff --git a/lua/codetyper/agent/scope.lua b/lua/codetyper/agent/scope.lua index b9c38c5..b1005ee 100644 --- a/lua/codetyper/agent/scope.lua +++ b/lua/codetyper/agent/scope.lua @@ -282,13 +282,21 @@ function M.resolve_scope_heuristic(bufnr, row, col) ending = nil, -- Python uses indentation }, javascript = { - start = "^%s*function%s+", - start_alt = "^%s*const%s+%w+%s*=%s*", + start = "^%s*export%s+function%s+", + start_alt = "^%s*function%s+", + start_alt2 = "^%s*export%s+const%s+%w+%s*=", + start_alt3 = "^%s*const%s+%w+%s*=%s*", + start_alt4 = "^%s*export%s+async%s+function%s+", + start_alt5 = "^%s*async%s+function%s+", ending = "^%s*}%s*$", }, typescript = { - start = "^%s*function%s+", - start_alt = "^%s*const%s+%w+%s*=%s*", + start = "^%s*export%s+function%s+", + start_alt = "^%s*function%s+", + start_alt2 = "^%s*export%s+const%s+%w+%s*=", + start_alt3 = "^%s*const%s+%w+%s*=%s*", + start_alt4 = "^%s*export%s+async%s+function%s+", + start_alt5 = "^%s*async%s+function%s+", ending = "^%s*}%s*$", }, } @@ -302,8 +310,13 @@ function M.resolve_scope_heuristic(bufnr, row, col) local start_line = nil for i = row, 1, -1 do local line = lines[i] - if line:match(lang_patterns.start) or - (lang_patterns.start_alt and line:match(lang_patterns.start_alt)) then + -- Check all start patterns + if line:match(lang_patterns.start) + or (lang_patterns.start_alt and line:match(lang_patterns.start_alt)) + or (lang_patterns.start_alt2 and line:match(lang_patterns.start_alt2)) + or (lang_patterns.start_alt3 and line:match(lang_patterns.start_alt3)) + or (lang_patterns.start_alt4 and line:match(lang_patterns.start_alt4)) + or (lang_patterns.start_alt5 and line:match(lang_patterns.start_alt5)) then start_line = i break end diff --git a/lua/codetyper/agent/search_replace.lua b/lua/codetyper/agent/search_replace.lua new file mode 100644 index 0000000..cba2812 --- /dev/null +++ b/lua/codetyper/agent/search_replace.lua @@ -0,0 +1,570 @@ +---@mod codetyper.agent.search_replace Search/Replace editing system +---@brief [[ +--- Implements SEARCH/REPLACE block parsing and fuzzy matching for reliable code edits. +--- Parses and applies SEARCH/REPLACE blocks from LLM responses. +---@brief ]] + +local M = {} + +---@class SearchReplaceBlock +---@field search string The text to search for +---@field replace string The text to replace with +---@field file_path string|nil Optional file path for multi-file edits + +---@class MatchResult +---@field start_line number 1-indexed start line +---@field end_line number 1-indexed end line +---@field start_col number 1-indexed start column (for partial line matches) +---@field end_col number 1-indexed end column +---@field strategy string Which matching strategy succeeded +---@field confidence number Match confidence (0.0-1.0) + +--- Parse SEARCH/REPLACE blocks from LLM response +--- Supports multiple formats: +--- Format 1 (dash style): +--- ------- SEARCH +--- old code +--- ======= +--- new code +--- +++++++ REPLACE +--- +--- Format 2 (claude style): +--- <<<<<<< SEARCH +--- old code +--- ======= +--- new code +--- >>>>>>> REPLACE +--- +--- Format 3 (simple): +--- [SEARCH] +--- old code +--- [REPLACE] +--- new code +--- [END] +--- +---@param response string LLM response text +---@return SearchReplaceBlock[] +function M.parse_blocks(response) + local blocks = {} + + -- Try dash-style format: ------- SEARCH ... ======= ... +++++++ REPLACE + for search, replace in response:gmatch("%-%-%-%-%-%-%-?%s*SEARCH%s*\n(.-)\n=======%s*\n(.-)\n%+%+%+%+%+%+%+?%s*REPLACE") do + table.insert(blocks, { search = search, replace = replace }) + end + + if #blocks > 0 then + return blocks + end + + -- Try claude-style format: <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE + for search, replace in response:gmatch("<<<<<<<[%s]*SEARCH%s*\n(.-)\n=======%s*\n(.-)\n>>>>>>>[%s]*REPLACE") do + table.insert(blocks, { search = search, replace = replace }) + end + + if #blocks > 0 then + return blocks + end + + -- Try simple format: [SEARCH] ... [REPLACE] ... [END] + for search, replace in response:gmatch("%[SEARCH%]%s*\n(.-)\n%[REPLACE%]%s*\n(.-)\n%[END%]") do + table.insert(blocks, { search = search, replace = replace }) + end + + if #blocks > 0 then + return blocks + end + + -- Try markdown diff format: ```diff ... ``` + local diff_block = response:match("```diff\n(.-)\n```") + if diff_block then + local old_lines = {} + local new_lines = {} + for line in diff_block:gmatch("[^\n]+") do + if line:match("^%-[^%-]") then + -- Removed line (starts with single -) + table.insert(old_lines, line:sub(2)) + elseif line:match("^%+[^%+]") then + -- Added line (starts with single +) + table.insert(new_lines, line:sub(2)) + elseif line:match("^%s") or line:match("^[^%-%+@]") then + -- Context line + table.insert(old_lines, line:match("^%s?(.*)")) + table.insert(new_lines, line:match("^%s?(.*)")) + end + end + if #old_lines > 0 or #new_lines > 0 then + table.insert(blocks, { + search = table.concat(old_lines, "\n"), + replace = table.concat(new_lines, "\n"), + }) + end + end + + return blocks +end + +--- Get indentation of a line +---@param line string +---@return string +local function get_indentation(line) + if not line then + return "" + end + return line:match("^(%s*)") or "" +end + +--- Normalize whitespace in a string (collapse multiple spaces to one) +---@param str string +---@return string +local function normalize_whitespace(str) + -- Wrap in parentheses to only return first value (gsub returns string + count) + return (str:gsub("%s+", " "):gsub("^%s*", ""):gsub("%s*$", "")) +end + +--- Trim trailing whitespace from each line +---@param str string +---@return string +local function trim_lines(str) + local lines = vim.split(str, "\n", { plain = true }) + for i, line in ipairs(lines) do + -- Wrap in parentheses to only get string, not count + lines[i] = (line:gsub("%s+$", "")) + end + return table.concat(lines, "\n") +end + +--- Calculate Levenshtein distance between two strings +---@param s1 string +---@param s2 string +---@return number +local function levenshtein(s1, s2) + local len1, len2 = #s1, #s2 + if len1 == 0 then + return len2 + end + if len2 == 0 then + return len1 + end + + local matrix = {} + for i = 0, len1 do + matrix[i] = { [0] = i } + end + for j = 0, len2 do + matrix[0][j] = j + end + + for i = 1, len1 do + for j = 1, len2 do + local cost = (s1:sub(i, i) == s2:sub(j, j)) and 0 or 1 + matrix[i][j] = math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost + ) + end + end + + return matrix[len1][len2] +end + +--- Calculate similarity ratio (0.0-1.0) between two strings +---@param s1 string +---@param s2 string +---@return number +local function similarity(s1, s2) + if s1 == s2 then + return 1.0 + end + local max_len = math.max(#s1, #s2) + if max_len == 0 then + return 1.0 + end + local distance = levenshtein(s1, s2) + return 1.0 - (distance / max_len) +end + +--- Strategy 1: Exact match +---@param content_lines string[] +---@param search_lines string[] +---@return MatchResult|nil +local function exact_match(content_lines, search_lines) + if #search_lines == 0 then + return nil + end + + for i = 1, #content_lines - #search_lines + 1 do + local match = true + for j = 1, #search_lines do + if content_lines[i + j - 1] ~= search_lines[j] then + match = false + break + end + end + if match then + return { + start_line = i, + end_line = i + #search_lines - 1, + start_col = 1, + end_col = #content_lines[i + #search_lines - 1], + strategy = "exact", + confidence = 1.0, + } + end + end + + return nil +end + +--- Strategy 2: Line-trimmed match (ignore trailing whitespace) +---@param content_lines string[] +---@param search_lines string[] +---@return MatchResult|nil +local function line_trimmed_match(content_lines, search_lines) + if #search_lines == 0 then + return nil + end + + local trimmed_search = {} + for _, line in ipairs(search_lines) do + table.insert(trimmed_search, (line:gsub("%s+$", ""))) + end + + for i = 1, #content_lines - #search_lines + 1 do + local match = true + for j = 1, #search_lines do + local trimmed_content = content_lines[i + j - 1]:gsub("%s+$", "") + if trimmed_content ~= trimmed_search[j] then + match = false + break + end + end + if match then + return { + start_line = i, + end_line = i + #search_lines - 1, + start_col = 1, + end_col = #content_lines[i + #search_lines - 1], + strategy = "line_trimmed", + confidence = 0.95, + } + end + end + + return nil +end + +--- Strategy 3: Indentation-flexible match (normalize indentation) +---@param content_lines string[] +---@param search_lines string[] +---@return MatchResult|nil +local function indentation_flexible_match(content_lines, search_lines) + if #search_lines == 0 then + return nil + end + + -- Get base indentation from search (first non-empty line) + local search_indent = "" + for _, line in ipairs(search_lines) do + if line:match("%S") then + search_indent = get_indentation(line) + break + end + end + + -- Strip common indentation from search + local stripped_search = {} + for _, line in ipairs(search_lines) do + if line:match("^" .. vim.pesc(search_indent)) then + table.insert(stripped_search, line:sub(#search_indent + 1)) + else + table.insert(stripped_search, line) + end + end + + for i = 1, #content_lines - #search_lines + 1 do + -- Get content indentation at this position + local content_indent = "" + for j = 0, #search_lines - 1 do + local line = content_lines[i + j] + if line:match("%S") then + content_indent = get_indentation(line) + break + end + end + + local match = true + for j = 1, #search_lines do + local content_line = content_lines[i + j - 1] + local expected = content_indent .. stripped_search[j] + + -- Compare with normalized indentation + if content_line:gsub("%s+$", "") ~= expected:gsub("%s+$", "") then + match = false + break + end + end + + if match then + return { + start_line = i, + end_line = i + #search_lines - 1, + start_col = 1, + end_col = #content_lines[i + #search_lines - 1], + strategy = "indentation_flexible", + confidence = 0.9, + } + end + end + + return nil +end + +--- Strategy 4: Block anchor match (match first/last lines, fuzzy middle) +---@param content_lines string[] +---@param search_lines string[] +---@return MatchResult|nil +local function block_anchor_match(content_lines, search_lines) + if #search_lines < 2 then + return nil + end + + local first_search = search_lines[1]:gsub("%s+$", "") + local last_search = search_lines[#search_lines]:gsub("%s+$", "") + + -- Find potential start positions + local candidates = {} + for i = 1, #content_lines - #search_lines + 1 do + local first_content = content_lines[i]:gsub("%s+$", "") + if similarity(first_content, first_search) > 0.8 then + -- Check if last line also matches + local last_idx = i + #search_lines - 1 + if last_idx <= #content_lines then + local last_content = content_lines[last_idx]:gsub("%s+$", "") + if similarity(last_content, last_search) > 0.8 then + -- Calculate overall similarity + local total_sim = 0 + for j = 1, #search_lines do + local c = content_lines[i + j - 1]:gsub("%s+$", "") + local s = search_lines[j]:gsub("%s+$", "") + total_sim = total_sim + similarity(c, s) + end + local avg_sim = total_sim / #search_lines + if avg_sim > 0.7 then + table.insert(candidates, { start = i, similarity = avg_sim }) + end + end + end + end + end + + -- Return best match + if #candidates > 0 then + table.sort(candidates, function(a, b) + return a.similarity > b.similarity + end) + local best = candidates[1] + return { + start_line = best.start, + end_line = best.start + #search_lines - 1, + start_col = 1, + end_col = #content_lines[best.start + #search_lines - 1], + strategy = "block_anchor", + confidence = best.similarity * 0.85, + } + end + + return nil +end + +--- Strategy 5: Whitespace-normalized match +---@param content_lines string[] +---@param search_lines string[] +---@return MatchResult|nil +local function whitespace_normalized_match(content_lines, search_lines) + if #search_lines == 0 then + return nil + end + + -- Normalize search lines + local norm_search = {} + for _, line in ipairs(search_lines) do + table.insert(norm_search, normalize_whitespace(line)) + end + + for i = 1, #content_lines - #search_lines + 1 do + local match = true + for j = 1, #search_lines do + local norm_content = normalize_whitespace(content_lines[i + j - 1]) + if norm_content ~= norm_search[j] then + match = false + break + end + end + if match then + return { + start_line = i, + end_line = i + #search_lines - 1, + start_col = 1, + end_col = #content_lines[i + #search_lines - 1], + strategy = "whitespace_normalized", + confidence = 0.8, + } + end + end + + return nil +end + +--- Find the best match for search text in content +---@param content string File content +---@param search string Text to search for +---@return MatchResult|nil +function M.find_match(content, search) + local content_lines = vim.split(content, "\n", { plain = true }) + local search_lines = vim.split(search, "\n", { plain = true }) + + -- Remove trailing empty lines from search + while #search_lines > 0 and search_lines[#search_lines]:match("^%s*$") do + table.remove(search_lines) + end + + if #search_lines == 0 then + return nil + end + + -- Try strategies in order of strictness + local strategies = { + exact_match, + line_trimmed_match, + indentation_flexible_match, + block_anchor_match, + whitespace_normalized_match, + } + + for _, strategy in ipairs(strategies) do + local result = strategy(content_lines, search_lines) + if result then + return result + end + end + + return nil +end + +--- Apply a single SEARCH/REPLACE block to content +---@param content string Original file content +---@param block SearchReplaceBlock +---@return string|nil new_content +---@return MatchResult|nil match_info +---@return string|nil error +function M.apply_block(content, block) + local match = M.find_match(content, block.search) + if not match then + return nil, nil, "Could not find search text in file" + end + + local content_lines = vim.split(content, "\n", { plain = true }) + local replace_lines = vim.split(block.replace, "\n", { plain = true }) + + -- Adjust indentation of replacement to match original + local original_indent = get_indentation(content_lines[match.start_line]) + local replace_indent = "" + for _, line in ipairs(replace_lines) do + if line:match("%S") then + replace_indent = get_indentation(line) + break + end + end + + -- Apply indentation adjustment + local adjusted_replace = {} + for _, line in ipairs(replace_lines) do + if line:match("^" .. vim.pesc(replace_indent)) then + table.insert(adjusted_replace, original_indent .. line:sub(#replace_indent + 1)) + elseif line:match("^%s*$") then + table.insert(adjusted_replace, "") + else + table.insert(adjusted_replace, original_indent .. line) + end + end + + -- Build new content + local new_lines = {} + for i = 1, match.start_line - 1 do + table.insert(new_lines, content_lines[i]) + end + for _, line in ipairs(adjusted_replace) do + table.insert(new_lines, line) + end + for i = match.end_line + 1, #content_lines do + table.insert(new_lines, content_lines[i]) + end + + return table.concat(new_lines, "\n"), match, nil +end + +--- Apply multiple SEARCH/REPLACE blocks to content +---@param content string Original file content +---@param blocks SearchReplaceBlock[] +---@return string new_content +---@return table results Array of {success: boolean, match: MatchResult|nil, error: string|nil} +function M.apply_blocks(content, blocks) + local current_content = content + local results = {} + + for _, block in ipairs(blocks) do + local new_content, match, err = M.apply_block(current_content, block) + if new_content then + current_content = new_content + table.insert(results, { success = true, match = match }) + else + table.insert(results, { success = false, error = err }) + end + end + + return current_content, results +end + +--- Apply SEARCH/REPLACE blocks to a buffer +---@param bufnr number Buffer number +---@param blocks SearchReplaceBlock[] +---@return boolean success +---@return string|nil error +function M.apply_to_buffer(bufnr, blocks) + if not vim.api.nvim_buf_is_valid(bufnr) then + return false, "Invalid buffer" + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, "\n") + + local new_content, results = M.apply_blocks(content, blocks) + + -- Check for any failures + local failures = {} + for i, result in ipairs(results) do + if not result.success then + table.insert(failures, string.format("Block %d: %s", i, result.error or "unknown error")) + end + end + + if #failures > 0 then + return false, table.concat(failures, "; ") + end + + -- Apply to buffer + local new_lines = vim.split(new_content, "\n", { plain = true }) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) + + return true, nil +end + +--- Check if response contains SEARCH/REPLACE blocks +---@param response string +---@return boolean +function M.has_blocks(response) + return #M.parse_blocks(response) > 0 +end + +return M diff --git a/lua/codetyper/agent/tools.lua b/lua/codetyper/agent/tools.lua index 7ad9a26..8ae21b2 100644 --- a/lua/codetyper/agent/tools.lua +++ b/lua/codetyper/agent/tools.lua @@ -219,4 +219,11 @@ function M.get_tool_names() return names end +--- Optional setup function for future extensibility +---@param opts table|nil Configuration options +function M.setup(opts) + -- Currently a no-op. Plugins or tests may call setup(); keep for compatibility. +end + return M + diff --git a/lua/codetyper/agent/tools/edit.lua b/lua/codetyper/agent/tools/edit.lua index b5ef998..6e7a11b 100644 --- a/lua/codetyper/agent/tools/edit.lua +++ b/lua/codetyper/agent/tools/edit.lua @@ -2,7 +2,7 @@ ---@brief [[ --- Tool for making targeted edits to files using search/replace. --- Implements multiple fallback strategies for robust matching. ---- Inspired by opencode's 9-strategy approach. +--- Multi-strategy approach for reliable editing. ---@brief ]] local Base = require("codetyper.agent.tools.base") diff --git a/lua/codetyper/agent/tools/init.lua b/lua/codetyper/agent/tools/init.lua index 386ece6..d6259ca 100644 --- a/lua/codetyper/agent/tools/init.lua +++ b/lua/codetyper/agent/tools/init.lua @@ -1,7 +1,7 @@ ---@mod codetyper.agent.tools Tool registry and orchestration ---@brief [[ --- Registry for LLM tools with execution and schema generation. ---- Inspired by avante.nvim's tool system. +--- Tool system for agent mode. ---@brief ]] local M = {} diff --git a/lua/codetyper/agent/ui.lua b/lua/codetyper/agent/ui.lua index a738955..6ae9709 100644 --- a/lua/codetyper/agent/ui.lua +++ b/lua/codetyper/agent/ui.lua @@ -29,6 +29,7 @@ local state = { is_open = false, log_listener_id = nil, referenced_files = {}, + selection_context = nil, -- Visual selection passed when opening } --- Namespace for highlights @@ -121,7 +122,9 @@ local function add_log_entry(entry) local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, -1, false) local line_num = #lines - vim.api.nvim_buf_set_lines(state.logs_buf, -1, -1, false, { formatted }) + -- Split formatted log into individual lines to avoid passing newline-containing items + local formatted_lines = vim.split(formatted, "\n") + vim.api.nvim_buf_set_lines(state.logs_buf, -1, -1, false, formatted_lines) -- Apply highlighting based on level local hl_map = { @@ -234,8 +237,16 @@ local function create_callbacks() on_complete = function() vim.schedule(function() - add_message("system", "Done.", "DiagnosticHint") - logs.info("Agent loop completed") + local changes_count = agent.get_changes_count() + if changes_count > 0 then + add_message("system", + string.format("Done. %d file(s) changed. Press d to review changes.", changes_count), + "DiagnosticHint") + logs.info(string.format("Agent completed with %d change(s)", changes_count)) + else + add_message("system", "Done.", "DiagnosticHint") + logs.info("Agent loop completed") + end M.focus_input() end) end, @@ -303,12 +314,15 @@ local function submit_input() "╔═══════════════════════════════════════════════════════════════╗", "║ [AGENT MODE] Can read/write files ║", "╠═══════════════════════════════════════════════════════════════╣", - "║ @ attach file | C-f current file | :CoderType switch mode ║", + "║ @ attach | C-f current file | d review changes ║", "╚═══════════════════════════════════════════════════════════════╝", "", }) vim.bo[state.chat_buf].modifiable = false end + -- Also clear collected diffs + local diff_review = require("codetyper.agent.diff_review") + diff_review.clear() return end @@ -317,6 +331,30 @@ local function submit_input() return end + if input == "/continue" then + if agent.is_running() then + add_message("system", "Agent is already running. Use /stop first.") + return + end + + if not agent.has_saved_session() then + add_message("system", "No saved session to continue.") + return + end + + local info = agent.get_saved_session_info() + if info then + add_message("system", string.format("Resuming session from %s...", info.saved_at)) + logs.info(string.format("Resuming: %d messages, iteration %d", info.messages, info.iteration)) + end + + local success = agent.continue_session(create_callbacks()) + if not success then + add_message("system", "Failed to resume session.") + end + return + end + -- Build file context local file_context = build_file_context() local file_count = vim.tbl_count(state.referenced_files) @@ -359,8 +397,15 @@ local function submit_input() -- Append file context to input local full_input = input + + -- Add selection context if present + local selection_ctx = M.get_selection_context() + if selection_ctx then + full_input = full_input .. "\n\n" .. selection_ctx + end + if file_context ~= "" then - full_input = input .. "\n\nATTACHED FILES:" .. file_context + full_input = full_input .. "\n\nATTACHED FILES:" .. file_context end logs.thinking("Starting...") @@ -494,12 +539,20 @@ local function update_logs_title() end --- Open the agent UI -function M.open() +---@param selection table|nil Visual selection context {text, start_line, end_line, filepath, filename, language} +function M.open(selection) if state.is_open then + -- If already open and new selection provided, add it as context + if selection and selection.text and selection.text ~= "" then + M.add_selection_context(selection) + end M.focus_input() return end + -- Store selection context + state.selection_context = selection + -- Clear previous state logs.clear() state.referenced_files = {} @@ -574,7 +627,7 @@ function M.open() "╔═══════════════════════════════════════════════════════════════╗", "║ [AGENT MODE] Can read/write files ║", "╠═══════════════════════════════════════════════════════════════╣", - "║ @ attach file | C-f current file | :CoderType switch mode ║", + "║ @ attach | C-f current file | d review changes ║", "╚═══════════════════════════════════════════════════════════════╝", "", }) @@ -607,6 +660,7 @@ function M.open() vim.keymap.set("n", "", M.focus_chat, input_opts) vim.keymap.set("n", "q", M.close, input_opts) vim.keymap.set("n", "", M.close, input_opts) + vim.keymap.set("n", "d", M.show_diff_review, input_opts) -- Set up keymaps for chat buffer local chat_opts = { buffer = state.chat_buf, noremap = true, silent = true } @@ -617,6 +671,7 @@ function M.open() vim.keymap.set("n", "", M.include_current_file, chat_opts) vim.keymap.set("n", "", M.focus_logs, chat_opts) vim.keymap.set("n", "q", M.close, chat_opts) + vim.keymap.set("n", "d", M.show_diff_review, chat_opts) -- Set up keymaps for logs buffer local logs_opts = { buffer = state.logs_buf, noremap = true, silent = true } @@ -650,6 +705,26 @@ function M.open() M.focus_input() logs.info("Agent ready") + -- Check for saved session and notify user + if agent.has_saved_session() then + vim.schedule(function() + local info = agent.get_saved_session_info() + if info then + add_message("system", + string.format("Saved session available (%s). Type /continue to resume.", info.saved_at), + "DiagnosticHint") + logs.info("Saved session found: " .. (info.prompt or ""):sub(1, 30) .. "...") + end + end) + end + + -- If we have a selection, show it as context + if selection and selection.text and selection.text ~= "" then + vim.schedule(function() + M.add_selection_context(selection) + end) + end + -- Log provider info local ok, codetyper = pcall(require, "codetyper") if ok then @@ -732,4 +807,101 @@ function M.is_open() return state.is_open end +--- Show the diff review for all changes made in this session +function M.show_diff_review() + local changes_count = agent.get_changes_count() + if changes_count == 0 then + utils.notify("No changes to review", vim.log.levels.INFO) + return + end + agent.show_diff_review() +end + +--- Add visual selection as context in the chat +---@param selection table Selection info {text, start_line, end_line, filepath, filename, language} +function M.add_selection_context(selection) + if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then + return + end + + state.selection_context = selection + + vim.bo[state.chat_buf].modifiable = true + + local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false) + + -- Format the selection display + local location = "" + if selection.filename then + location = selection.filename + if selection.start_line then + location = location .. ":" .. selection.start_line + if selection.end_line and selection.end_line ~= selection.start_line then + location = location .. "-" .. selection.end_line + end + end + end + + local new_lines = { + "", + "┌─ Selected Code ─────────────────────", + "│ " .. location, + "│", + } + + -- Add the selected code + for _, line in ipairs(vim.split(selection.text, "\n")) do + table.insert(new_lines, "│ " .. line) + end + + table.insert(new_lines, "│") + table.insert(new_lines, "└──────────────────────────────────────") + table.insert(new_lines, "") + table.insert(new_lines, "Describe what you'd like to do with this code.") + + for _, line in ipairs(new_lines) do + table.insert(lines, line) + end + + vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, lines) + vim.bo[state.chat_buf].modifiable = false + + -- Scroll to bottom + if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then + local line_count = vim.api.nvim_buf_line_count(state.chat_buf) + vim.api.nvim_win_set_cursor(state.chat_win, { line_count, 0 }) + end + + -- Also add the file to referenced_files for context + if selection.filepath and selection.filepath ~= "" then + state.referenced_files[selection.filename or "selection"] = selection.filepath + end + + logs.info("Selection added: " .. location) +end + +--- Get selection context for agent prompt +---@return string|nil Selection context string +function M.get_selection_context() + if not state.selection_context or not state.selection_context.text then + return nil + end + + local sel = state.selection_context + local location = sel.filename or "unknown" + if sel.start_line then + location = location .. ":" .. sel.start_line + if sel.end_line and sel.end_line ~= sel.start_line then + location = location .. "-" .. sel.end_line + end + end + + return string.format( + "SELECTED CODE (%s):\n```%s\n%s\n```", + location, + sel.language or "", + sel.text + ) +end + return M diff --git a/lua/codetyper/agent/worker.lua b/lua/codetyper/agent/worker.lua index cc3601e..5e48af1 100644 --- a/lua/codetyper/agent/worker.lua +++ b/lua/codetyper/agent/worker.lua @@ -83,6 +83,19 @@ local function needs_more_context(response) return false end +--- Check if response contains SEARCH/REPLACE blocks +---@param response string +---@return boolean +local function has_search_replace_blocks(response) + if not response then + return false + end + -- Check for any of the supported SEARCH/REPLACE formats + return response:match("<<<<<<<%s*SEARCH") ~= nil + or response:match("%-%-%-%-%-%-%-?%s*SEARCH") ~= nil + 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 @@ -107,6 +120,13 @@ local function clean_response(response, filetype) -- Use [%s%S] to match any character including newlines (Lua's . doesn't match newlines) cleaned = cleaned:gsub("/@[%s%S]-@/", "") + -- IMPORTANT: If response contains SEARCH/REPLACE blocks, preserve them! + -- Don't extract from markdown or remove "explanations" that are actually part of the format + if has_search_replace_blocks(cleaned) then + -- Just trim whitespace and return - the blocks will be parsed by search_replace module + return cleaned:match("^%s*(.-)%s*$") or cleaned + end + -- Try to extract code from markdown code blocks -- Match ```language\n...\n``` or just ```\n...\n``` local code_block = cleaned:match("```[%w]*\n(.-)\n```") @@ -352,6 +372,45 @@ local function format_indexed_context(indexed_context) return "\n\n--- Project Context ---\n" .. table.concat(parts, "\n") end +--- Check if this is an inline prompt (tags in target file, not a coder file) +---@param event table +---@return boolean +local function is_inline_prompt(event) + -- Inline prompts have a range with start_line/end_line from tag detection + -- and the source file is the same as target (not a .coder. file) + if not event.range or not event.range.start_line then + return false + end + -- Check if source path (if any) equals target, or if target has no .coder. in it + local target = event.target_path or "" + if target:match("%.coder%.") then + return false + end + return true +end + +--- Build file content with marked region for inline prompts +---@param lines string[] File lines +---@param start_line number 1-indexed +---@param end_line number 1-indexed +---@param prompt_content string The prompt inside the tags +---@return string +local function build_marked_file_content(lines, start_line, end_line, prompt_content) + local result = {} + for i, line in ipairs(lines) do + if i == start_line then + -- Mark the start of the region to be replaced + table.insert(result, ">>> REPLACE THIS REGION (lines " .. start_line .. "-" .. end_line .. ") <<<") + table.insert(result, "--- User request: " .. prompt_content:gsub("\n", " "):sub(1, 100) .. " ---") + end + table.insert(result, line) + if i == end_line then + table.insert(result, ">>> END OF REGION TO REPLACE <<<") + end + end + return table.concat(result, "\n") +end + --- Build prompt for code generation ---@param event table PromptEvent ---@return string prompt @@ -361,11 +420,13 @@ local function build_prompt(event) -- Get target file content for context local target_content = "" + local target_lines = {} if event.target_path then local ok, lines = pcall(function() return vim.fn.readfile(event.target_path) end) if ok and lines then + target_lines = lines target_content = table.concat(lines, "\n") end end @@ -458,6 +519,93 @@ local function build_prompt(event) system_prompt = intent_mod.get_prompt_modifier(event.intent) end + -- SPECIAL HANDLING: Inline prompts with /@ ... @/ tags + -- Uses SEARCH/REPLACE block format for reliable code editing + 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 + + user_prompt = string.format( + [[You are editing a %s file: %s + +TASK: %s + +FULL FILE CONTENT: +```%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:]], + filetype, + vim.fn.fnamemodify(event.target_path or "", ":t"), + event.prompt_content, + filetype, + table.concat(file_content_clean, "\n"):sub(1, 8000) -- Limit size + ) + + 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 + -- If we have a scope (function/method), include it in the prompt if event.scope_text and event.scope and event.scope.type ~= "file" then local scope_type = event.scope.type @@ -490,6 +638,11 @@ Return ONLY the complete %s with implementation. No explanations, no duplicates. event.prompt_content, scope_type ) + -- Remind the LLM not to repeat the original file content; ask for only the new/updated code or a unified diff + user_prompt = user_prompt .. [[ + +IMPORTANT: Do NOT repeat the existing code provided above. Return ONLY the new or modified code (the updated function body). If you modify the file, prefer outputting a unified diff patch using standard diff headers (--- a/ / +++ b/ and @@ hunks). No explanations, no markdown, no code fences. +]] -- For other replacement intents, provide the full scope to transform elseif event.intent and intent_mod.is_replacement(event.intent) then user_prompt = string.format( @@ -530,6 +683,18 @@ Output only the code to insert, no explanations.]], extra_context, event.prompt_content ) + + -- Remind the LLM not to repeat the full file content; ask for only the new/modified code or unified diff + user_prompt = user_prompt .. [[ + +IMPORTANT: Do NOT repeat the full file content shown above. Return ONLY the new or modified code required to satisfy the request. If you modify the file, prefer outputting a unified diff patch using standard diff headers (--- a/ / +++ b/ and @@ hunks). No explanations, no markdown, no code fences. +]] + + -- Remind the LLM not to repeat the original file content; ask for only the inserted code or a unified diff + user_prompt = user_prompt .. [[ + +IMPORTANT: Do NOT repeat the surrounding code provided above. Return ONLY the code to insert (the new snippet). If you modify multiple parts of the file, prefer outputting a unified diff patch using standard diff headers (--- a/ / +++ b/ and @@ hunks). No explanations, no markdown, no code fences. +]] end else -- No scope resolved, use full file context diff --git a/lua/codetyper/ask.lua b/lua/codetyper/ask.lua index c3180db..16c8962 100644 --- a/lua/codetyper/ask.lua +++ b/lua/codetyper/ask.lua @@ -1,4 +1,4 @@ ----@mod codetyper.ask Ask window for Codetyper.nvim (similar to avante.nvim) +---@mod codetyper.ask Ask window for Codetyper.nvim local M = {} @@ -26,6 +26,7 @@ local state = { agent_mode = false, -- Whether agent mode is enabled (can make file changes) log_listener_id = nil, -- Listener ID for LLM logs show_logs = true, -- Whether to show LLM logs in chat + selection_context = nil, -- Visual selection passed when opening } --- Get the ask window configuration @@ -369,13 +370,21 @@ local function remove_log_listener() end --- Open the ask panel -function M.open() +---@param selection table|nil Visual selection context {text, start_line, end_line, filepath, filename, language} +function M.open(selection) -- Use the is_open() function which validates window state if M.is_open() then + -- If already open and new selection provided, add it as context + if selection and selection.text and selection.text ~= "" then + M.add_selection_context(selection) + end M.focus_input() return end + -- Store selection context for use in questions + state.selection_context = selection + local dims = calculate_dimensions() -- Store the target width @@ -479,6 +488,70 @@ function M.open() -- Focus the input window and start insert mode vim.api.nvim_set_current_win(state.input_win) vim.cmd("startinsert") + + -- If we have a selection, show it as context + if selection and selection.text and selection.text ~= "" then + vim.schedule(function() + M.add_selection_context(selection) + end) + end +end + +--- Add visual selection as context in the chat +---@param selection table Selection info {text, start_line, end_line, filepath, filename, language} +function M.add_selection_context(selection) + if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then + return + end + + state.selection_context = selection + + vim.bo[state.output_buf].modifiable = true + + local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false) + + -- Format the selection display + local location = "" + if selection.filename then + location = selection.filename + if selection.start_line then + location = location .. ":" .. selection.start_line + if selection.end_line and selection.end_line ~= selection.start_line then + location = location .. "-" .. selection.end_line + end + end + end + + local new_lines = { + "", + "┌─ 📋 Selected Code ─────────────────", + "│ " .. location, + "│", + } + + -- Add the selected code with syntax hints + local lang = selection.language or "text" + for _, line in ipairs(vim.split(selection.text, "\n")) do + table.insert(new_lines, "│ " .. line) + end + + table.insert(new_lines, "│") + table.insert(new_lines, "└─────────────────────────────────────") + table.insert(new_lines, "") + table.insert(new_lines, "Ask about this code or describe what you'd like to do with it.") + + for _, line in ipairs(new_lines) do + table.insert(lines, line) + end + + vim.api.nvim_buf_set_lines(state.output_buf, 0, -1, false, lines) + vim.bo[state.output_buf].modifiable = false + + -- Scroll to bottom + if state.output_win and vim.api.nvim_win_is_valid(state.output_win) then + local line_count = vim.api.nvim_buf_line_count(state.output_buf) + vim.api.nvim_win_set_cursor(state.output_win, { line_count, 0 }) + end end --- Show file picker for @ mentions @@ -903,18 +976,49 @@ local function continue_submit(question, intent, context, file_context, file_cou local client = llm.get_client() - -- Build full prompt WITH file contents - local full_prompt = question - if file_context ~= "" then - full_prompt = "USER QUESTION: " - .. question - .. "\n\n" - .. "ATTACHED FILE CONTENTS (please analyze these):" - .. file_context + -- Build recent conversation context (limit to last N entries) + local history_context = "" + do + local max_entries = 8 + local total = #state.history + local start_i = 1 + if total > max_entries then + start_i = total - max_entries + 1 + end + if total > 0 then + history_context = "\n\n=== PREVIOUS CONVERSATION ===\n" + for i = start_i, total do + local m = state.history[i] + local role = (m.role == "assistant") and "ASSISTANT" or "USER" + history_context = history_context .. role .. ": " .. (m.content or "") .. "\n" + end + history_context = history_context .. "=== END PREVIOUS CONVERSATION ===\n\n" + end end - -- Also add current file if no files were explicitly attached - if file_count == 0 and context.current_content and context.current_content ~= "" then + -- Build full prompt starting with recent conversation + user question + local full_prompt = history_context .. "USER QUESTION: " .. question + + -- Add visual selection context if present + if state.selection_context and state.selection_context.text and state.selection_context.text ~= "" then + local sel = state.selection_context + local location = sel.filename or "unknown" + if sel.start_line then + location = location .. ":" .. sel.start_line + if sel.end_line and sel.end_line ~= sel.start_line then + location = location .. "-" .. sel.end_line + end + end + full_prompt = full_prompt .. "\n\nSELECTED CODE (" .. location .. "):\n```" .. (sel.language or "") .. "\n" + full_prompt = full_prompt .. sel.text .. "\n```" + end + + if file_context ~= "" then + full_prompt = full_prompt .. "\n\nATTACHED FILE CONTENTS (please analyze these):" .. file_context + end + + -- Also add current file if no files were explicitly attached and no selection + if file_count == 0 and not state.selection_context and context.current_content and context.current_content ~= "" then full_prompt = "USER QUESTION: " .. question .. "\n\n" diff --git a/lua/codetyper/autocmds.lua b/lua/codetyper/autocmds.lua index 93f4987..b39f36c 100644 --- a/lua/codetyper/autocmds.lua +++ b/lua/codetyper/autocmds.lua @@ -535,6 +535,163 @@ function M.check_for_closed_prompt() is_processing = false end +--- Process a single prompt through the scheduler +--- This is the core processing logic used by both automatic and manual modes +---@param bufnr number Buffer number +---@param prompt table Prompt object with start_line, end_line, content +---@param current_file string Current file path +---@param skip_processed_check? boolean Skip the processed check (for manual mode) +function M.process_single_prompt(bufnr, prompt, current_file, skip_processed_check) + local parser = require("codetyper.parser") + local scheduler = require("codetyper.agent.scheduler") + + if not prompt.content or prompt.content == "" then + return + end + + -- Ensure scheduler is running + if not scheduler.status().running then + scheduler.start() + end + + -- Generate unique key for this prompt + local prompt_key = get_prompt_key(bufnr, prompt) + + -- Skip if already processed (unless overridden for manual mode) + if not skip_processed_check and processed_prompts[prompt_key] then + return + end + + -- Mark as processed + processed_prompts[prompt_key] = true + + -- Process this prompt + vim.schedule(function() + local queue = require("codetyper.agent.queue") + local patch_mod = require("codetyper.agent.patch") + local intent_mod = require("codetyper.agent.intent") + local scope_mod = require("codetyper.agent.scope") + local logs_panel = require("codetyper.logs_panel") + + -- Open logs panel to show progress + logs_panel.ensure_open() + + -- Take buffer snapshot + local snapshot = patch_mod.snapshot_buffer(bufnr, { + start_line = prompt.start_line, + end_line = prompt.end_line, + }) + + -- Get target path - for coder files, get the target; for regular files, use self + local target_path + local is_from_coder_file = utils.is_coder_file(current_file) + if is_from_coder_file then + target_path = utils.get_target_path(current_file) + else + target_path = current_file + end + + -- Read attached files before cleaning + local attached_files = read_attached_files(prompt.content, current_file) + + -- Clean prompt content (strip file references) + local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content)) + + -- Resolve scope in target file FIRST (need it to adjust intent) + -- Only resolve scope if NOT from coder file (line numbers don't apply) + local target_bufnr = vim.fn.bufnr(target_path) + local scope = nil + local scope_text = nil + local scope_range = nil + + if not is_from_coder_file then + -- Prompt is in the actual source file, use line position for scope + if target_bufnr == -1 then + target_bufnr = bufnr + end + scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1) + if scope and scope.type ~= "file" then + scope_text = scope.text + scope_range = { + start_line = scope.range.start_row, + end_line = scope.range.end_row, + } + end + else + -- Prompt is in coder file - load target if needed + if target_bufnr == -1 then + target_bufnr = vim.fn.bufadd(target_path) + if target_bufnr ~= 0 then + vim.fn.bufload(target_bufnr) + end + end + end + + -- Detect intent from prompt + local intent = intent_mod.detect(cleaned) + + -- IMPORTANT: If prompt is inside a function/method and intent is "add", + -- override to "complete" since we're completing the function body + -- But NOT for coder files - they should use "add/append" by default + if not is_from_coder_file and scope and (scope.type == "function" or scope.type == "method") then + if intent.type == "add" or intent.action == "insert" or intent.action == "append" then + -- Override to complete the function instead of adding new code + intent = { + type = "complete", + scope_hint = "function", + confidence = intent.confidence, + action = "replace", + keywords = intent.keywords, + } + end + end + + -- For coder files, default to "add" with "append" action + if is_from_coder_file and (intent.action == "replace" or intent.type == "complete") then + intent = { + type = intent.type == "complete" and "add" or intent.type, + confidence = intent.confidence, + action = "append", + keywords = intent.keywords, + } + end + + -- Determine priority based on intent + local priority = 2 + if intent.type == "fix" or intent.type == "complete" then + priority = 1 + elseif intent.type == "test" or intent.type == "document" then + priority = 3 + end + + -- Enqueue the event + queue.enqueue({ + id = queue.generate_id(), + bufnr = bufnr, + range = { start_line = prompt.start_line, end_line = prompt.end_line }, + timestamp = os.clock(), + changedtick = snapshot.changedtick, + content_hash = snapshot.content_hash, + prompt_content = cleaned, + target_path = target_path, + priority = priority, + status = "pending", + attempt_count = 0, + intent = intent, + scope = scope, + scope_text = scope_text, + scope_range = scope_range, + attached_files = attached_files, + }) + + local scope_info = scope + and scope.type ~= "file" + and string.format(" [%s: %s]", scope.type, scope.name or "anonymous") + or "" + utils.notify(string.format("Prompt queued: %s%s", intent.type, scope_info), vim.log.levels.INFO) + end) +end + --- Check and process all closed prompts in the buffer (works on ANY file) function M.check_all_prompts() local parser = require("codetyper.parser") @@ -563,146 +720,7 @@ function M.check_all_prompts() end for _, prompt in ipairs(prompts) do - if prompt.content and prompt.content ~= "" then - -- Generate unique key for this prompt - local prompt_key = get_prompt_key(bufnr, prompt) - - -- Skip if already processed - if processed_prompts[prompt_key] then - goto continue - end - - -- Mark as processed - processed_prompts[prompt_key] = true - - -- Process this prompt - vim.schedule(function() - local queue = require("codetyper.agent.queue") - local patch_mod = require("codetyper.agent.patch") - local intent_mod = require("codetyper.agent.intent") - local scope_mod = require("codetyper.agent.scope") - local logs_panel = require("codetyper.logs_panel") - - -- Open logs panel to show progress - logs_panel.ensure_open() - - -- Take buffer snapshot - local snapshot = patch_mod.snapshot_buffer(bufnr, { - start_line = prompt.start_line, - end_line = prompt.end_line, - }) - - -- Get target path - for coder files, get the target; for regular files, use self - local target_path - local is_from_coder_file = utils.is_coder_file(current_file) - if is_from_coder_file then - target_path = utils.get_target_path(current_file) - else - target_path = current_file - end - - -- Read attached files before cleaning - local attached_files = read_attached_files(prompt.content, current_file) - - -- Clean prompt content (strip file references) - local cleaned = parser.clean_prompt(parser.strip_file_references(prompt.content)) - - -- Resolve scope in target file FIRST (need it to adjust intent) - -- Only resolve scope if NOT from coder file (line numbers don't apply) - local target_bufnr = vim.fn.bufnr(target_path) - local scope = nil - local scope_text = nil - local scope_range = nil - - if not is_from_coder_file then - -- Prompt is in the actual source file, use line position for scope - if target_bufnr == -1 then - target_bufnr = bufnr - end - scope = scope_mod.resolve_scope(target_bufnr, prompt.start_line, 1) - if scope and scope.type ~= "file" then - scope_text = scope.text - scope_range = { - start_line = scope.range.start_row, - end_line = scope.range.end_row, - } - end - else - -- Prompt is in coder file - load target if needed - if target_bufnr == -1 then - target_bufnr = vim.fn.bufadd(target_path) - if target_bufnr ~= 0 then - vim.fn.bufload(target_bufnr) - end - end - end - - -- Detect intent from prompt - local intent = intent_mod.detect(cleaned) - - -- IMPORTANT: If prompt is inside a function/method and intent is "add", - -- override to "complete" since we're completing the function body - -- But NOT for coder files - they should use "add/append" by default - if not is_from_coder_file and scope and (scope.type == "function" or scope.type == "method") then - if intent.type == "add" or intent.action == "insert" or intent.action == "append" then - -- Override to complete the function instead of adding new code - intent = { - type = "complete", - scope_hint = "function", - confidence = intent.confidence, - action = "replace", - keywords = intent.keywords, - } - end - end - - -- For coder files, default to "add" with "append" action - if is_from_coder_file and (intent.action == "replace" or intent.type == "complete") then - intent = { - type = intent.type == "complete" and "add" or intent.type, - confidence = intent.confidence, - action = "append", - keywords = intent.keywords, - } - end - - -- Determine priority based on intent - local priority = 2 - if intent.type == "fix" or intent.type == "complete" then - priority = 1 - elseif intent.type == "test" or intent.type == "document" then - priority = 3 - end - - -- Enqueue the event - queue.enqueue({ - id = queue.generate_id(), - bufnr = bufnr, - range = { start_line = prompt.start_line, end_line = prompt.end_line }, - timestamp = os.clock(), - changedtick = snapshot.changedtick, - content_hash = snapshot.content_hash, - prompt_content = cleaned, - target_path = target_path, - priority = priority, - status = "pending", - attempt_count = 0, - intent = intent, - scope = scope, - scope_text = scope_text, - scope_range = scope_range, - attached_files = attached_files, - }) - - local scope_info = scope - and scope.type ~= "file" - and string.format(" [%s: %s]", scope.type, scope.name or "anonymous") - or "" - utils.notify(string.format("Prompt queued: %s%s", intent.type, scope_info), vim.log.levels.INFO) - end) - - ::continue:: - end + M.process_single_prompt(bufnr, prompt, current_file) end end @@ -803,14 +821,17 @@ end --- Reset processed prompts for a buffer (useful for re-processing) ---@param bufnr? number Buffer number (default: current) -function M.reset_processed(bufnr) +---@param silent? boolean Suppress notification (default: false) +function M.reset_processed(bufnr, silent) bufnr = bufnr or vim.api.nvim_get_current_buf() for key, _ in pairs(processed_prompts) do if key:match("^" .. bufnr .. ":") then processed_prompts[key] = nil end end - utils.notify("Prompt history cleared - prompts can be re-processed") + if not silent then + utils.notify("Prompt history cleared - prompts can be re-processed") + end end --- Track if we already opened the split for this buffer diff --git a/lua/codetyper/cmp_source/init.lua b/lua/codetyper/cmp_source/init.lua index 5e4c89d..fc9b321 100644 --- a/lua/codetyper/cmp_source/init.lua +++ b/lua/codetyper/cmp_source/init.lua @@ -172,6 +172,36 @@ local function get_buffer_completions(prefix, bufnr) return items end +--- Try to get Copilot suggestion if plugin is installed +---@param prefix string +---@return string|nil suggestion +local function get_copilot_suggestion(prefix) + -- Try copilot.lua suggestion API first + local ok, copilot_suggestion = pcall(require, "copilot.suggestion") + if ok and copilot_suggestion and type(copilot_suggestion.get_suggestion) == "function" then + local ok2, suggestion = pcall(copilot_suggestion.get_suggestion) + if ok2 and suggestion and suggestion ~= "" then + -- Only return if suggestion seems to start with prefix (best-effort) + if prefix == "" or suggestion:lower():match(prefix:lower(), 1) then + return suggestion + else + return suggestion + end + end + end + + -- Fallback: try older copilot module if present + local ok3, copilot = pcall(require, "copilot") + if ok3 and copilot and type(copilot.get_suggestion) == "function" then + local ok4, suggestion = pcall(copilot.get_suggestion) + if ok4 and suggestion and suggestion ~= "" then + return suggestion + end + end + + return nil +end + --- Create new cmp source instance function source.new() return setmetatable({}, { __index = source }) @@ -251,6 +281,32 @@ function source:complete(params, callback) end end + -- If Copilot is installed, prefer its suggestion as a top-priority completion + local ok_cp, _ = pcall(require, "copilot") + if ok_cp then + local suggestion = nil + local ok_sug, res = pcall(get_copilot_suggestion, prefix) + if ok_sug then + suggestion = res + end + if suggestion and suggestion ~= "" then + -- Truncate suggestion to first line for label display + local first_line = suggestion:match("([^ +]+)") or suggestion + -- Avoid duplicates + if not seen[first_line] then + seen[first_line] = true + table.insert(items, 1, { + label = first_line, + kind = 1, + detail = "[copilot]", + documentation = suggestion, + sortText = "0" .. first_line, + }) + end + end + end + callback({ items = items, isIncomplete = #items >= 50, diff --git a/lua/codetyper/commands.lua b/lua/codetyper/commands.lua index ab08885..e1bb288 100644 --- a/lua/codetyper/commands.lua +++ b/lua/codetyper/commands.lua @@ -234,10 +234,11 @@ local function cmd_gitignore() gitignore.force_update() end ---- Open ask panel -local function cmd_ask() +--- Open ask panel (with optional visual selection) +---@param selection table|nil Visual selection info +local function cmd_ask(selection) local ask = require("codetyper.ask") - ask.open() + ask.open(selection) end --- Close ask panel @@ -258,10 +259,11 @@ local function cmd_ask_clear() ask.clear_history() end ---- Open agent panel -local function cmd_agent() +--- Open agent panel (with optional visual selection) +---@param selection table|nil Visual selection info +local function cmd_agent(selection) local agent_ui = require("codetyper.agent.ui") - agent_ui.open() + agent_ui.open(selection) end --- Close agent panel @@ -482,9 +484,10 @@ end --- Transform inline /@ @/ tags in current file --- Works on ANY file, not just .coder.* files +--- Uses the same processing logic as automatic mode for consistent results local function cmd_transform() local parser = require("codetyper.parser") - local llm = require("codetyper.llm") + local autocmds = require("codetyper.autocmds") local logs_panel = require("codetyper.logs_panel") local logs = require("codetyper.agent.logs") @@ -506,113 +509,25 @@ local function cmd_transform() -- Open the logs panel to show generation progress logs_panel.open() - logs.info("Transform started: " .. #prompts .. " prompt(s)") + logs.info("Transform started: " .. #prompts .. " prompt(s) in " .. vim.fn.fnamemodify(filepath, ":t")) utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO) - -- Build context for this file - local ext = vim.fn.fnamemodify(filepath, ":e") - local context = llm.build_context(filepath, "code_generation") + -- Reset processed prompts tracking so we can re-process them (silent mode) + autocmds.reset_processed(bufnr, true) - -- Process prompts in reverse order (bottom to top) to maintain line numbers - local sorted_prompts = {} - for i = #prompts, 1, -1 do - table.insert(sorted_prompts, prompts[i]) - end - - -- Track how many are being processed - local pending = #sorted_prompts - local completed = 0 - local errors = 0 - - -- Process each prompt - for _, prompt in ipairs(sorted_prompts) do - local clean_prompt = parser.clean_prompt(prompt.content) - local prompt_type = parser.detect_prompt_type(prompt.content) - - -- Build enhanced user prompt - local enhanced_prompt = "TASK: " .. clean_prompt .. "\n\n" - enhanced_prompt = enhanced_prompt .. "REQUIREMENTS:\n" - enhanced_prompt = enhanced_prompt .. "- Generate ONLY " .. (context.language or "code") .. " code\n" - enhanced_prompt = enhanced_prompt .. "- NO markdown code blocks (no ```)\n" - enhanced_prompt = enhanced_prompt .. "- NO explanations or comments about what you did\n" - enhanced_prompt = enhanced_prompt .. "- Match the coding style of the existing file exactly\n" - enhanced_prompt = enhanced_prompt .. "- Output must be ready to insert directly into the file\n" - - logs.info("Processing: " .. clean_prompt:sub(1, 40) .. "...") - utils.notify("Processing: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO) - - -- Generate code for this prompt - llm.generate(enhanced_prompt, context, function(response, err) - if err then - logs.error("Failed: " .. err) - utils.notify("Failed: " .. err, vim.log.levels.ERROR) - errors = errors + 1 - elseif response then - -- Replace the prompt tag with generated code - vim.schedule(function() - -- Get current buffer lines - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - - -- Calculate the exact range to replace - local start_line = prompt.start_line - local end_line = prompt.end_line - - -- Find the full lines containing the tags - local start_line_content = lines[start_line] or "" - local end_line_content = lines[end_line] or "" - - -- Check if there's content before the opening tag on the same line - local codetyper = require("codetyper") - local config = codetyper.get_config() - local before_tag = "" - local after_tag = "" - - local open_pos = start_line_content:find(utils.escape_pattern(config.patterns.open_tag)) - if open_pos and open_pos > 1 then - before_tag = start_line_content:sub(1, open_pos - 1) - end - - local close_pos = end_line_content:find(utils.escape_pattern(config.patterns.close_tag)) - if close_pos then - local after_close = close_pos + #config.patterns.close_tag - if after_close <= #end_line_content then - after_tag = end_line_content:sub(after_close) - end - end - - -- Build the replacement lines - local replacement_lines = vim.split(response, "\n", { plain = true }) - - -- Add before/after content if any - if before_tag ~= "" and #replacement_lines > 0 then - replacement_lines[1] = before_tag .. replacement_lines[1] - end - if after_tag ~= "" and #replacement_lines > 0 then - replacement_lines[#replacement_lines] = replacement_lines[#replacement_lines] .. after_tag - end - - -- Replace the lines in buffer - vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, replacement_lines) - - completed = completed + 1 - if completed + errors >= pending then - local msg = "Transform complete: " .. completed .. " succeeded, " .. errors .. " failed" - logs.info(msg) - utils.notify(msg, errors > 0 and vim.log.levels.WARN or vim.log.levels.INFO) - end - end) - end - end) - end + -- 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) ---@param end_line number End line (1-indexed) local function cmd_transform_range(start_line, end_line) local parser = require("codetyper.parser") - local llm = require("codetyper.llm") + local autocmds = require("codetyper.autocmds") local logs_panel = require("codetyper.logs_panel") local logs = require("codetyper.agent.logs") @@ -646,85 +561,11 @@ local function cmd_transform_range(start_line, end_line) utils.notify("Found " .. #prompts .. " prompt(s) in selection to transform...", vim.log.levels.INFO) - -- Build context for this file - local context = llm.build_context(filepath, "code_generation") - - -- Process prompts in reverse order (bottom to top) to maintain line numbers - local sorted_prompts = {} - for i = #prompts, 1, -1 do - table.insert(sorted_prompts, prompts[i]) - end - - local pending = #sorted_prompts - local completed = 0 - local errors = 0 - - for _, prompt in ipairs(sorted_prompts) do + -- Process each prompt using the same logic as automatic mode (skip processed check for manual mode) + for _, prompt in ipairs(prompts) do local clean_prompt = parser.clean_prompt(prompt.content) - - local enhanced_prompt = "TASK: " .. clean_prompt .. "\n\n" - enhanced_prompt = enhanced_prompt .. "REQUIREMENTS:\n" - enhanced_prompt = enhanced_prompt .. "- Generate ONLY " .. (context.language or "code") .. " code\n" - enhanced_prompt = enhanced_prompt .. "- NO markdown code blocks (no ```)\n" - enhanced_prompt = enhanced_prompt .. "- NO explanations or comments about what you did\n" - enhanced_prompt = enhanced_prompt .. "- Match the coding style of the existing file exactly\n" - enhanced_prompt = enhanced_prompt .. "- Output must be ready to insert directly into the file\n" - logs.info("Processing: " .. clean_prompt:sub(1, 40) .. "...") - utils.notify("Processing: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO) - - llm.generate(enhanced_prompt, context, function(response, err) - if err then - logs.error("Failed: " .. err) - utils.notify("Failed: " .. err, vim.log.levels.ERROR) - errors = errors + 1 - elseif response then - vim.schedule(function() - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local p_start_line = prompt.start_line - local p_end_line = prompt.end_line - - local start_line_content = lines[p_start_line] or "" - local end_line_content = lines[p_end_line] or "" - - local codetyper = require("codetyper") - local config = codetyper.get_config() - local before_tag = "" - local after_tag = "" - - local open_pos = start_line_content:find(utils.escape_pattern(config.patterns.open_tag)) - if open_pos and open_pos > 1 then - before_tag = start_line_content:sub(1, open_pos - 1) - end - - local close_pos = end_line_content:find(utils.escape_pattern(config.patterns.close_tag)) - if close_pos then - local after_close = close_pos + #config.patterns.close_tag - if after_close <= #end_line_content then - after_tag = end_line_content:sub(after_close) - end - end - - local replacement_lines = vim.split(response, "\n", { plain = true }) - - if before_tag ~= "" and #replacement_lines > 0 then - replacement_lines[1] = before_tag .. replacement_lines[1] - end - if after_tag ~= "" and #replacement_lines > 0 then - replacement_lines[#replacement_lines] = replacement_lines[#replacement_lines] .. after_tag - end - - vim.api.nvim_buf_set_lines(bufnr, p_start_line - 1, p_end_line, false, replacement_lines) - - completed = completed + 1 - if completed + errors >= pending then - local msg = "Transform complete: " .. completed .. " succeeded, " .. errors .. " failed" - logs.info(msg) - utils.notify(msg, errors > 0 and vim.log.levels.WARN or vim.log.levels.INFO) - end - end) - end - end) + autocmds.process_single_prompt(bufnr, prompt, filepath, true) end end @@ -862,9 +703,10 @@ local function cmd_forget(pattern) end --- Transform a single prompt at cursor position +--- Uses the same processing logic as automatic mode for consistent results local function cmd_transform_at_cursor() local parser = require("codetyper.parser") - local llm = require("codetyper.llm") + local autocmds = require("codetyper.autocmds") local logs_panel = require("codetyper.logs_panel") local logs = require("codetyper.agent.logs") @@ -888,70 +730,11 @@ local function cmd_transform_at_cursor() logs_panel.open() local clean_prompt = parser.clean_prompt(prompt.content) - local context = llm.build_context(filepath, "code_generation") - logs.info("Transform cursor: " .. clean_prompt:sub(1, 40) .. "...") - - -- Build enhanced user prompt - local enhanced_prompt = "TASK: " .. clean_prompt .. "\n\n" - enhanced_prompt = enhanced_prompt .. "REQUIREMENTS:\n" - enhanced_prompt = enhanced_prompt .. "- Generate ONLY " .. (context.language or "code") .. " code\n" - enhanced_prompt = enhanced_prompt .. "- NO markdown code blocks (no ```)\n" - enhanced_prompt = enhanced_prompt .. "- NO explanations or comments about what you did\n" - enhanced_prompt = enhanced_prompt .. "- Match the coding style of the existing file exactly\n" - enhanced_prompt = enhanced_prompt .. "- Output must be ready to insert directly into the file\n" - utils.notify("Transforming: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO) - llm.generate(enhanced_prompt, context, function(response, err) - if err then - logs.error("Transform failed: " .. err) - utils.notify("Transform failed: " .. err, vim.log.levels.ERROR) - return - end - - if response then - vim.schedule(function() - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - local start_line = prompt.start_line - local end_line = prompt.end_line - - local start_line_content = lines[start_line] or "" - local end_line_content = lines[end_line] or "" - - local codetyper = require("codetyper") - local config = codetyper.get_config() - local before_tag = "" - local after_tag = "" - - local open_pos = start_line_content:find(utils.escape_pattern(config.patterns.open_tag)) - if open_pos and open_pos > 1 then - before_tag = start_line_content:sub(1, open_pos - 1) - end - - local close_pos = end_line_content:find(utils.escape_pattern(config.patterns.close_tag)) - if close_pos then - local after_close = close_pos + #config.patterns.close_tag - if after_close <= #end_line_content then - after_tag = end_line_content:sub(after_close) - end - end - - local replacement_lines = vim.split(response, "\n", { plain = true }) - - if before_tag ~= "" and #replacement_lines > 0 then - replacement_lines[1] = before_tag .. replacement_lines[1] - end - if after_tag ~= "" and #replacement_lines > 0 then - replacement_lines[#replacement_lines] = replacement_lines[#replacement_lines] .. after_tag - end - - vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, replacement_lines) - logs.info("Transform complete!") - utils.notify("Transform complete!", vim.log.levels.INFO) - end) - end - end) + -- Use the same processing logic as automatic mode (skip processed check for manual mode) + autocmds.process_single_prompt(bufnr, prompt, filepath, true) end --- Main command dispatcher @@ -1178,9 +961,14 @@ function M.setup() end, { desc = "View tree.log" }) -- Ask panel commands - vim.api.nvim_create_user_command("CoderAsk", function() - cmd_ask() - end, { desc = "Open Ask panel" }) + vim.api.nvim_create_user_command("CoderAsk", function(opts) + local selection = nil + -- Check if called from visual mode (range is set) + if opts.range > 0 then + selection = utils.get_visual_selection() + end + cmd_ask(selection) + end, { range = true, desc = "Open Ask panel (with optional visual selection)" }) vim.api.nvim_create_user_command("CoderAskToggle", function() cmd_ask_toggle() @@ -1206,9 +994,14 @@ function M.setup() end, { range = true, desc = "Transform /@ @/ tags in visual selection" }) -- Agent commands - vim.api.nvim_create_user_command("CoderAgent", function() - cmd_agent() - end, { desc = "Open Agent panel" }) + vim.api.nvim_create_user_command("CoderAgent", function(opts) + local selection = nil + -- Check if called from visual mode (range is set) + if opts.range > 0 then + selection = utils.get_visual_selection() + end + cmd_agent(selection) + end, { range = true, desc = "Open Agent panel (with optional visual selection)" }) vim.api.nvim_create_user_command("CoderAgentToggle", function() cmd_agent_toggle() @@ -1462,6 +1255,145 @@ function M.setup() credentials.interactive_switch_provider() end, { desc = "Switch active LLM provider" }) + -- Conflict mode commands + vim.api.nvim_create_user_command("CoderConflictToggle", function() + local patch = require("codetyper.agent.patch") + local current = patch.is_conflict_mode() + patch.configure({ use_conflict_mode = not current }) + utils.notify("Conflict mode " .. (not current and "enabled" or "disabled"), vim.log.levels.INFO) + end, { desc = "Toggle conflict mode for code changes" }) + + vim.api.nvim_create_user_command("CoderConflictResolveAll", function(opts) + local conflict = require("codetyper.agent.conflict") + local bufnr = vim.api.nvim_get_current_buf() + local keep = opts.args ~= "" and opts.args or "theirs" + if not vim.tbl_contains({ "ours", "theirs", "both", "none" }, keep) then + utils.notify("Invalid option. Use: ours, theirs, both, or none", vim.log.levels.ERROR) + return + end + conflict.resolve_all(bufnr, keep) + utils.notify("Resolved all conflicts with: " .. keep, vim.log.levels.INFO) + end, { + nargs = "?", + complete = function() return { "ours", "theirs", "both", "none" } end, + desc = "Resolve all conflicts (ours/theirs/both/none)" + }) + + vim.api.nvim_create_user_command("CoderConflictNext", function() + local conflict = require("codetyper.agent.conflict") + conflict.goto_next(vim.api.nvim_get_current_buf()) + end, { desc = "Go to next conflict" }) + + vim.api.nvim_create_user_command("CoderConflictPrev", function() + local conflict = require("codetyper.agent.conflict") + conflict.goto_prev(vim.api.nvim_get_current_buf()) + end, { desc = "Go to previous conflict" }) + + vim.api.nvim_create_user_command("CoderConflictStatus", function() + local conflict = require("codetyper.agent.conflict") + local patch = require("codetyper.agent.patch") + local bufnr = vim.api.nvim_get_current_buf() + local count = conflict.count_conflicts(bufnr) + local mode = patch.is_conflict_mode() and "enabled" or "disabled" + utils.notify(string.format("Conflicts in buffer: %d | Conflict mode: %s", count, mode), vim.log.levels.INFO) + end, { desc = "Show conflict status" }) + + vim.api.nvim_create_user_command("CoderConflictMenu", function() + local conflict = require("codetyper.agent.conflict") + local bufnr = vim.api.nvim_get_current_buf() + -- Ensure conflicts are processed first (sets up highlights and keymaps) + conflict.process(bufnr) + conflict.show_floating_menu(bufnr) + end, { desc = "Show conflict resolution menu" }) + + -- Manual commands to accept conflicts + vim.api.nvim_create_user_command("CoderConflictAcceptCurrent", function() + local conflict = require("codetyper.agent.conflict") + local bufnr = vim.api.nvim_get_current_buf() + conflict.process(bufnr) -- Ensure keymaps are set up + conflict.accept_ours(bufnr) + end, { desc = "Accept current (original) code" }) + + vim.api.nvim_create_user_command("CoderConflictAcceptIncoming", function() + local conflict = require("codetyper.agent.conflict") + local bufnr = vim.api.nvim_get_current_buf() + conflict.process(bufnr) -- Ensure keymaps are set up + conflict.accept_theirs(bufnr) + end, { desc = "Accept incoming (AI) code" }) + + vim.api.nvim_create_user_command("CoderConflictAcceptBoth", function() + local conflict = require("codetyper.agent.conflict") + local bufnr = vim.api.nvim_get_current_buf() + conflict.process(bufnr) + conflict.accept_both(bufnr) + end, { desc = "Accept both versions" }) + + vim.api.nvim_create_user_command("CoderConflictAcceptNone", function() + local conflict = require("codetyper.agent.conflict") + local bufnr = vim.api.nvim_get_current_buf() + conflict.process(bufnr) + conflict.accept_none(bufnr) + end, { desc = "Delete conflict (accept none)" }) + + vim.api.nvim_create_user_command("CoderConflictAutoMenu", function() + local conflict = require("codetyper.agent.conflict") + local conf = conflict.get_config() + local new_state = not conf.auto_show_menu + conflict.configure({ auto_show_menu = new_state, auto_show_next_menu = new_state }) + utils.notify("Auto-show conflict menu " .. (new_state and "enabled" or "disabled"), vim.log.levels.INFO) + end, { desc = "Toggle auto-show conflict menu after code injection" }) + + -- Initialize conflict module + local conflict = require("codetyper.agent.conflict") + conflict.setup() + + -- Linter validation commands + vim.api.nvim_create_user_command("CoderLintCheck", function() + local linter = require("codetyper.agent.linter") + local bufnr = vim.api.nvim_get_current_buf() + linter.validate_after_injection(bufnr, nil, nil, function(result) + if result then + if not result.has_errors and not result.has_warnings then + utils.notify("No lint errors found", vim.log.levels.INFO) + end + end + end) + end, { desc = "Check current buffer for lint errors" }) + + vim.api.nvim_create_user_command("CoderLintFix", function() + local linter = require("codetyper.agent.linter") + local bufnr = vim.api.nvim_get_current_buf() + local line_count = vim.api.nvim_buf_line_count(bufnr) + local result = linter.check_region(bufnr, 1, line_count) + if result.has_errors or result.has_warnings then + linter.request_ai_fix(bufnr, result) + else + utils.notify("No lint errors to fix", vim.log.levels.INFO) + end + end, { desc = "Request AI to fix lint errors in current buffer" }) + + vim.api.nvim_create_user_command("CoderLintQuickfix", function() + local linter = require("codetyper.agent.linter") + local bufnr = vim.api.nvim_get_current_buf() + local line_count = vim.api.nvim_buf_line_count(bufnr) + local result = linter.check_region(bufnr, 1, line_count) + if #result.diagnostics > 0 then + linter.show_in_quickfix(bufnr, result) + else + utils.notify("No lint errors to show", vim.log.levels.INFO) + end + end, { desc = "Show lint errors in quickfix list" }) + + vim.api.nvim_create_user_command("CoderLintToggleAuto", function() + local conflict = require("codetyper.agent.conflict") + local linter = require("codetyper.agent.linter") + local linter_config = linter.get_config() + local new_state = not linter_config.auto_save + linter.configure({ auto_save = new_state }) + conflict.configure({ lint_after_accept = new_state, auto_fix_lint_errors = new_state }) + utils.notify("Auto lint check " .. (new_state and "enabled" or "disabled"), vim.log.levels.INFO) + end, { desc = "Toggle automatic lint checking after code acceptance" }) + -- Setup default keymaps M.setup_keymaps() end diff --git a/lua/codetyper/config.lua b/lua/codetyper/config.lua index ec8ef8c..dd98ffb 100644 --- a/lua/codetyper/config.lua +++ b/lua/codetyper/config.lua @@ -20,7 +20,7 @@ local defaults = { model = "gemini-2.0-flash", }, copilot = { - model = "gpt-4o", -- Uses GitHub Copilot authentication + model = "claude-sonnet-4", -- Uses GitHub Copilot authentication }, }, window = { diff --git a/lua/codetyper/cost.lua b/lua/codetyper/cost.lua index 3e95922..33c3442 100644 --- a/lua/codetyper/cost.lua +++ b/lua/codetyper/cost.lua @@ -100,7 +100,7 @@ M.pricing = { ["gpt-image-1"] = { input = 5.00, cached_input = 1.25, output = nil }, ["gpt-image-1-mini"] = { input = 2.00, cached_input = 0.20, output = nil }, - -- Claude models (Anthropic) + -- Claude models ["claude-3-opus"] = { input = 15.00, cached_input = 7.50, output = 75.00 }, ["claude-3-sonnet"] = { input = 3.00, cached_input = 1.50, output = 15.00 }, ["claude-3-haiku"] = { input = 0.25, cached_input = 0.125, output = 1.25 }, diff --git a/lua/codetyper/credentials.lua b/lua/codetyper/credentials.lua index e4d5156..606c706 100644 --- a/lua/codetyper/credentials.lua +++ b/lua/codetyper/credentials.lua @@ -221,10 +221,40 @@ M.default_models = { claude = "claude-sonnet-4-20250514", openai = "gpt-4o", gemini = "gemini-2.0-flash", - copilot = "gpt-4o", + copilot = "claude-sonnet-4", ollama = "deepseek-coder:6.7b", } +--- Available models for Copilot (GitHub Copilot Chat API) +--- Models are ordered by capability/cost (most capable first) +M.copilot_models = { + -- GPT-5 series + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1", + "gpt-5-codex", + "gpt-5", + "gpt-5-mini", + -- GPT-4 series + "gpt-4.1", + "gpt-4o", + -- Claude models + "claude-opus-4.5", + "claude-sonnet-4.5", + "claude-sonnet-4", + "claude-haiku-4.5", + -- Gemini models + "gemini-2.5-pro", + "gemini-3-pro", + "gemini-3-flash", + -- Other models + "grok-code-fast-1", + "raptor-mini", +} + --- Interactive command to add/update API key function M.interactive_add() local providers = { "claude", "openai", "gemini", "copilot", "ollama" } @@ -280,25 +310,45 @@ end function M.interactive_copilot_config() utils.notify("Copilot uses OAuth from copilot.lua/copilot.vim - no API key needed", vim.log.levels.INFO) - -- Just ask for model - local default_model = M.default_models.copilot - vim.ui.input({ - prompt = string.format("Copilot model (default: %s): ", default_model), - default = default_model, - }, function(model) - if model == nil then + -- Get current model if configured + local current_model = M.get_model("copilot") or M.default_models.copilot + + -- Build model options with "Custom..." option + local model_options = vim.deepcopy(M.copilot_models) + table.insert(model_options, "Custom...") + + vim.ui.select(model_options, { + prompt = "Select Copilot model (current: " .. current_model .. "):", + format_item = function(item) + if item == current_model then + return item .. " [current]" + end + return item + end, + }, function(choice) + if choice == nil then return -- Cancelled end - if model == "" then - model = default_model + if choice == "Custom..." then + -- Allow custom model input + vim.ui.input({ + prompt = "Enter custom model name: ", + default = current_model, + }, function(custom_model) + if custom_model and custom_model ~= "" then + M.save_and_notify("copilot", { + model = custom_model, + configured = true, + }) + end + end) + else + M.save_and_notify("copilot", { + model = choice, + configured = true, + }) end - - M.save_and_notify("copilot", { - model = model, - -- Mark as configured even without API key - configured = true, - }) end) end diff --git a/lua/codetyper/gitignore.lua b/lua/codetyper/gitignore.lua index f2db666..170a144 100644 --- a/lua/codetyper/gitignore.lua +++ b/lua/codetyper/gitignore.lua @@ -117,9 +117,16 @@ function M.add_to_gitignore() end --- Ensure coder files are in .gitignore (called on setup) +--- Only adds to .gitignore if in a git project (has .git/ folder) +--- Does NOT ask for permission - silently adds entries ---@param auto_gitignore? boolean Override auto_gitignore setting (default: true) ---@return boolean Success status function M.ensure_ignored(auto_gitignore) + -- Only add to gitignore if this is a git project + if not utils.is_git_project() then + return false -- Not a git project, skip + end + -- Default to true if not specified if auto_gitignore == nil then -- Try to get from config if available @@ -140,7 +147,46 @@ function M.ensure_ignored(auto_gitignore) return true end - return M.add_to_gitignore() + -- Silently add to gitignore (no notifications unless there's an error) + return M.add_to_gitignore_silent() +end + +--- Add coder patterns to .gitignore silently (no notifications) +---@return boolean Success status +function M.add_to_gitignore_silent() + local gitignore_path = M.get_gitignore_path() + if not gitignore_path then + return false + end + + local content = utils.read_file(gitignore_path) + local patterns_to_add = {} + + if content then + local _, missing = all_patterns_exist(content) + if #missing == 0 then + return true + end + patterns_to_add = missing + else + content = "" + patterns_to_add = IGNORE_PATTERNS + end + + local patterns_str = table.concat(patterns_to_add, "\n") + + if content == "" then + content = CODER_COMMENT .. "\n" .. patterns_str .. "\n" + else + local newline = content:sub(-1) == "\n" and "" or "\n" + if not content:match(utils.escape_pattern(CODER_COMMENT)) then + content = content .. newline .. "\n" .. CODER_COMMENT .. "\n" .. patterns_str .. "\n" + else + content = content .. newline .. patterns_str .. "\n" + end + end + + return utils.write_file(gitignore_path, content) end --- Remove coder patterns from .gitignore diff --git a/lua/codetyper/llm/copilot.lua b/lua/codetyper/llm/copilot.lua index fa985f4..b7fd44e 100644 --- a/lua/codetyper/llm/copilot.lua +++ b/lua/codetyper/llm/copilot.lua @@ -439,10 +439,9 @@ function M.generate_with_tools(messages, context, tool_definitions, callback) local tools_module = require("codetyper.agent.tools") local agent_prompts = require("codetyper.prompts.agent") - -- Build system prompt with agent instructions + -- Build system prompt with agent instructions and project context local system_prompt = llm.build_system_prompt(context) - system_prompt = system_prompt .. "\n\n" .. agent_prompts.system - system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions + system_prompt = system_prompt .. "\n\n" .. agent_prompts.build_system_prompt() -- Format messages for Copilot (OpenAI-compatible format) local copilot_messages = { { role = "system", content = system_prompt } } @@ -471,9 +470,21 @@ function M.generate_with_tools(messages, context, tool_definitions, callback) role = "assistant", content = type(msg.content) == "string" and msg.content or nil, } - -- Preserve tool_calls for the API - if msg.tool_calls then - assistant_msg.tool_calls = msg.tool_calls + -- Convert tool_calls to OpenAI format for the API + if msg.tool_calls and #msg.tool_calls > 0 then + assistant_msg.tool_calls = {} + for _, tc in ipairs(msg.tool_calls) do + -- Convert from parsed format {id, name, parameters} to OpenAI format + local openai_tc = { + id = tc.id, + type = "function", + ["function"] = { + name = tc.name, + arguments = vim.json.encode(tc.parameters or {}), + }, + } + table.insert(assistant_msg.tool_calls, openai_tc) + end -- Ensure content is not nil when tool_calls present if assistant_msg.content == nil then assistant_msg.content = "" @@ -497,6 +508,7 @@ function M.generate_with_tools(messages, context, tool_definitions, callback) temperature = 0.3, stream = false, tools = tools_module.to_openai_format(), + tool_choice = "auto", -- Encourage the model to use tools when appropriate } local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com") diff --git a/lua/codetyper/prompts/agent.lua b/lua/codetyper/prompts/agent.lua index 67dd9f4..3cd8e72 100644 --- a/lua/codetyper/prompts/agent.lua +++ b/lua/codetyper/prompts/agent.lua @@ -4,87 +4,98 @@ local M = {} +--- Build the system prompt with project context +---@return string System prompt with context +function M.build_system_prompt() + local base = M.system + + -- Add project context + local ok, context_builder = pcall(require, "codetyper.agent.context_builder") + if ok then + local context = context_builder.build_full_context() + if context and context ~= "" then + base = base .. "\n\n=== PROJECT CONTEXT ===\n" .. context .. "\n=== END PROJECT CONTEXT ===\n" + end + end + + return base .. "\n\n" .. M.tool_instructions +end + --- System prompt for agent mode M.system = - [[You are an expert AI coding assistant integrated into Neovim. You help developers by reading, writing, and modifying code files, as well as running shell commands. + [[You are an expert AI coding assistant integrated into Neovim. You MUST use the provided tools to accomplish tasks. -## YOUR CAPABILITIES +## CRITICAL: YOU MUST USE TOOLS -You have access to these tools - USE THEM to accomplish tasks: +**NEVER output code in your response text.** Instead, you MUST call the write_file tool to create files. + +WRONG (do NOT do this): +```python +print("hello") +``` + +RIGHT (do this instead): +Call the write_file tool with path="hello.py" and content="print(\"hello\")\n" + +## AVAILABLE TOOLS ### File Operations -- **view**: Read any file. ALWAYS read files before modifying them. Parameters: path (string) -- **write**: Create new files or completely replace existing ones. Use for new files. Parameters: path (string), content (string) -- **edit**: Make precise edits to existing files using search/replace. Parameters: path (string), old_string (string), new_string (string) -- **glob**: Find files by pattern (e.g., "**/*.lua"). Parameters: pattern (string), path (optional) -- **grep**: Search file contents with regex. Parameters: pattern (string), path (optional) +- **read_file**: Read any file. Parameters: path (string) +- **write_file**: Create or overwrite files. Parameters: path (string), content (string) +- **edit_file**: Modify existing files. Parameters: path (string), find (string), replace (string) +- **list_directory**: List files and directories. Parameters: path (string, optional), recursive (boolean, optional) +- **search_files**: Find files. Parameters: pattern (string), content (string), path (string) +- **delete_file**: Delete a file. Parameters: path (string), reason (string) ### Shell Commands -- **bash**: Run shell commands (git, npm, make, etc.). User approves each command. Parameters: command (string) +- **bash**: Run shell commands. Parameters: command (string), timeout (number, optional) ## HOW TO WORK -1. **UNDERSTAND FIRST**: Use view, glob, or grep to understand the codebase before making changes. +1. **To create a file**: Call write_file with the path and complete content +2. **To modify a file**: First call read_file, then call edit_file with exact find/replace strings +3. **To run commands**: Call bash with the command string -2. **MAKE CHANGES**: Use write for new files, edit for modifications. - - For edit: The "old_string" parameter must match file content EXACTLY (including whitespace) - - Include enough context in "old_string" to be unique - - For write: Provide complete file content +## EXAMPLE -3. **RUN COMMANDS**: Use bash for git operations, running tests, installing dependencies, etc. +User: "Create a Python hello world" -4. **ITERATE**: After each tool result, decide if more actions are needed. +Your action: Call the write_file tool: +- path: "hello.py" +- content: "#!/usr/bin/env python3\nprint('Hello, World!')\n" -## EXAMPLE WORKFLOW +Then provide a brief summary. -User: "Create a new React component for a login form" +## RULES -Your approach: -1. Use glob to see project structure (glob pattern="**/*.tsx") -2. Use view to check existing component patterns -3. Use write to create the new component file -4. Use write to create a test file if appropriate -5. Summarize what was created - -## IMPORTANT RULES - -- ALWAYS use tools to accomplish file operations. Don't just describe what to do - DO IT. -- Read files before editing to ensure your "old_string" matches exactly. -- When creating files, write complete, working code. -- When editing, preserve existing code style and conventions. -- If a file path is provided, use it. If not, infer from context. -- For multi-file tasks, handle each file sequentially. - -## OUTPUT STYLE - -- Be concise in explanations -- Use tools proactively to complete tasks -- After making changes, briefly summarize what was done +1. **ALWAYS call tools** - Never just show code in text, always use write_file +2. **Read before editing** - Use read_file before edit_file +3. **Complete files** - write_file content must be the entire file +4. **Be precise** - edit_file "find" must match exactly including whitespace +5. **Act, don't describe** - Use tools to make changes, don't just explain what to do ]] --- Tool usage instructions appended to system prompt M.tool_instructions = [[ -## TOOL USAGE +## MANDATORY TOOL CALLING -When you need to perform an action, call the appropriate tool. You can call tools to: -- Read files with view (parameters: path) -- Create new files with write (parameters: path, content) -- Modify existing files with edit (parameters: path, old_string, new_string) - read first! -- Find files by pattern with glob (parameters: pattern, path) -- Search file contents with grep (parameters: pattern, path) -- Run shell commands with bash (parameters: command) +You MUST call tools to perform actions. Your response should include tool calls, not code blocks. -After receiving a tool result, continue working: -- If more actions are needed, call another tool -- When the task is complete, provide a brief summary +When the user asks you to create a file: +→ Call write_file with path and content parameters -## CRITICAL RULES +When the user asks you to modify a file: +→ Call read_file first, then call edit_file -1. **Always read before editing**: Use view before edit to ensure exact matches -2. **Be precise with edits**: The "old_string" parameter must match the file content EXACTLY -3. **Create complete files**: When using write, provide fully working code -4. **User approval required**: File writes, edits, and bash commands need approval -5. **Don't guess**: If unsure about file structure, use glob or grep +When the user asks you to run a command: +→ Call bash with the command + +## REMEMBER + +- Outputting code in triple backticks does NOT create a file +- You must explicitly call write_file to create any file +- After tool execution, provide only a brief summary +- Do not repeat code that was written - just confirm what was done ]] --- Prompt for when agent finishes diff --git a/lua/codetyper/tree.lua b/lua/codetyper/tree.lua index 89c4739..d2e08fe 100644 --- a/lua/codetyper/tree.lua +++ b/lua/codetyper/tree.lua @@ -273,9 +273,15 @@ local function is_project_initialized(root) end --- Initialize tree logging (called on setup) +--- Only creates .coder/ folder for git projects (has .git/ folder) ---@param force? boolean Force re-initialization even if cached ---@return boolean success function M.setup(force) + -- Only initialize for git projects + if not utils.is_git_project() then + return false -- Not a git project, don't create .coder/ + end + local coder_folder = M.get_coder_folder() if not coder_folder then return false @@ -291,9 +297,9 @@ function M.setup(force) return true end - -- Ensure .coder folder exists + -- Ensure .coder folder exists (silent, no asking) if not M.ensure_coder_folder() then - utils.notify("Failed to create .coder folder", vim.log.levels.ERROR) + -- Silent failure - don't bother user return false end diff --git a/lua/codetyper/utils.lua b/lua/codetyper/utils.lua index c4e72be..c13369c 100644 --- a/lua/codetyper/utils.lua +++ b/lua/codetyper/utils.lua @@ -25,6 +25,25 @@ function M.get_project_root() return current end +--- Check if current working directory IS a git repository root +--- Only returns true if .git folder exists directly in cwd (not in parent) +---@return boolean +function M.is_git_project() + local cwd = vim.fn.getcwd() + local git_path = cwd .. "/.git" + -- Check if .git exists as a directory or file (for worktrees) + return vim.fn.isdirectory(git_path) == 1 or vim.fn.filereadable(git_path) == 1 +end + +--- Get git root directory (only if cwd is a git root) +---@return string|nil Git root or nil if not a git project +function M.get_git_root() + if M.is_git_project() then + return vim.fn.getcwd() + end + return nil +end + --- Check if a file is a coder file ---@param filepath string File path to check ---@return boolean @@ -123,4 +142,56 @@ function M.escape_pattern(str) return str:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") end +--- Get visual selection text +--- Call this BEFORE leaving visual mode or use marks '< and '> +---@return table|nil Selection info {text: string, start_line: number, end_line: number, filepath: string} or nil +function M.get_visual_selection() + local mode = vim.fn.mode() + + -- Get marks - works in visual mode or after visual selection + local start_line = vim.fn.line("'<") + local end_line = vim.fn.line("'>") + local start_col = vim.fn.col("'<") + local end_col = vim.fn.col("'>") + + -- If marks are not set (both 0), return nil + if start_line == 0 and end_line == 0 then + return nil + end + + local bufnr = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + + if #lines == 0 then + return nil + end + + -- Handle visual line mode - get full lines + local text + if mode == "V" or mode == "\22" then -- Visual line or Visual block + text = table.concat(lines, "\n") + else + -- Character-wise visual mode - trim first and last line + if #lines == 1 then + text = lines[1]:sub(start_col, end_col) + else + lines[1] = lines[1]:sub(start_col) + lines[#lines] = lines[#lines]:sub(1, end_col) + text = table.concat(lines, "\n") + end + end + + local filepath = vim.fn.expand("%:p") + local filename = vim.fn.expand("%:t") + + return { + text = text, + start_line = start_line, + end_line = end_line, + filepath = filepath, + filename = filename, + language = vim.bo[bufnr].filetype, + } +end + return M