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 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 09:00:35 -05:00
parent f5df1a9ac0
commit 60577f8951
37 changed files with 6107 additions and 1240 deletions

View File

@@ -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 `<CR>` 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: `<leader>ctt` (cursor/visual), `<leader>ctT` (all)
- **Transform Commands** - Transform /@ @/ tags inline
- `:CoderTransform`, `:CoderTransformCursor`, `:CoderTransformVisual`
- Default keymaps: `<leader>ctt`, `<leader>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
- `<leader>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

778
README.md

File diff suppressed because it is too large Load Diff

View File

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

561
llms.txt
View File

@@ -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 |
| `<CR>` | Show menu when on conflict |
**Menu keymaps:**
| Key | Description |
|-----|-------------|
| `1` | Accept current |
| `2` | Accept incoming |
| `3` | Accept both |
| `4` | Accept none |
| `q`/`<Esc>` | 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`/`<Esc>` - 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 <task>` | `:CoderAgenticRun <task>` | 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 <mode>` | `:CoderAutoSet <mode>` | 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 <type>` | 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 <mode>` | `:CoderAutoSet` | Set mode |
### Brain & Memory
| Command | Description |
|---------|-------------|
| `:CoderMemories` | Show memories |
| `:CoderForget [pattern]` | Clear memories |
| `:CoderBrain [action]` | Brain management |
| `:CoderFeedback <type>` | 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 |
|-----|------|-------------|
| `<leader>ctt` | Normal | Transform tag at cursor |
| `<leader>ctt` | Visual | Transform selected tags |
| `<leader>ctT` | Normal | Transform all tags |
| `<leader>ca` | Normal | Toggle Agent panel |
| `<leader>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 |
|-----|-------------|
| `<CR>` | Submit |
| `Ctrl+c` | Stop agent |
| `q` | Close |
### Logs Panel Keymaps
| Key | Description |
|-----|-------------|
| `q`/`<Esc>` | Close |
### Cost Window Keymaps
| Key | Description |
|-----|-------------|
| `q`/`<Esc>` | 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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 <leader><n> to run a command, or <leader>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", "<leader>s", submit, opts)
vim.keymap.set("n", "<CR><CR>", 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 <leader>r / <C-r> in insert mode
vim.keymap.set("n", "<leader>r", run_project_inspect, opts)
vim.keymap.set("i", "<C-r>", function()
vim.schedule(run_project_inspect)
end, { buffer = state.buf, noremap = true, silent = true })
-- If suggested commands provided, create per-command keymaps <leader>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 = "<leader>" .. 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 <leader>0 to run all suggested commands
vim.keymap.set("n", "<leader>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", "<Esc>", 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", "<leader>r", run_project_inspect, { buffer = state.buf, noremap = true, silent = true })
vim.keymap.set("i", "<C-r>", 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()

View File

@@ -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", "<Down>", M.next, list_opts)
vim.keymap.set("n", "<Up>", M.prev, list_opts)
vim.keymap.set("n", "<CR>", 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", "<Esc>", 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", "<Tab>", 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", "<Esc>", 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

View File

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

View File

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

View File

@@ -62,6 +62,11 @@ local intent_patterns = {
"bug",
"error",
"issue",
"update",
"modify",
"change",
"adjust",
"tweak",
},
scope_hint = "function",
action = "replace",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <leader>d to review changes.", changes_count),
"DiagnosticHint")
logs.info(string.format("Agent completed with %d change(s)", changes_count))
else
add_message("system", "Done.", "DiagnosticHint")
logs.info("Agent loop completed")
end
M.focus_input()
end)
end,
@@ -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 | <leader>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 | <leader>d review changes ",
"╚═══════════════════════════════════════════════════════════════╝",
"",
})
@@ -607,6 +660,7 @@ function M.open()
vim.keymap.set("n", "<Tab>", M.focus_chat, input_opts)
vim.keymap.set("n", "q", M.close, input_opts)
vim.keymap.set("n", "<Esc>", M.close, input_opts)
vim.keymap.set("n", "<leader>d", M.show_diff_review, input_opts)
-- Set up keymaps for chat buffer
local chat_opts = { buffer = state.chat_buf, noremap = true, silent = true }
@@ -617,6 +671,7 @@ function M.open()
vim.keymap.set("n", "<C-f>", M.include_current_file, chat_opts)
vim.keymap.set("n", "<Tab>", M.focus_logs, chat_opts)
vim.keymap.set("n", "q", M.close, chat_opts)
vim.keymap.set("n", "<leader>d", M.show_diff_review, chat_opts)
-- Set up keymaps for logs buffer
local logs_opts = { buffer = state.logs_buf, noremap = true, silent = true }
@@ -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

View File

@@ -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/<file> / +++ b/<file> 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/<file> / +++ b/<file> 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/<file> / +++ b/<file> and @@ hunks). No explanations, no markdown, no code fences.
]]
end
else
-- No scope resolved, use full file context

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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