7 Commits

Author SHA1 Message Date
258b29f5f0 Fix: correct import path for preferences module 2026-01-16 12:04:09 -05:00
6a69a524ea Refactor: standardize 'agent' -> 'agents' for params and prompts 2026-01-16 12:01:42 -05:00
10c1de8843 Refactor: Restructure project into core, features, adapters, and config modules 2026-01-16 11:52:46 -05:00
4fb52596e3 Refactoring code 2026-01-16 11:33:11 -05:00
9dfb52ac8d Refactoring code 2026-01-16 10:45:34 -05:00
c9be0cf804 Fixing build 2026-01-16 09:44:17 -05:00
60577f8951 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>
2026-01-16 09:00:35 -05:00
130 changed files with 8634 additions and 3208 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

@@ -2,7 +2,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Autocommand group name
local AUGROUP = "Codetyper"
@@ -43,7 +43,7 @@ local function schedule_tree_update()
end
tree_update_timer = vim.defer_fn(function()
local tree = require("codetyper.tree")
local tree = require("codetyper.support.tree")
tree.update_tree_log()
tree_update_timer = nil
end, TREE_UPDATE_DEBOUNCE_MS)
@@ -179,7 +179,7 @@ function M.setup()
group = group,
pattern = "*.coder.*",
callback = function(ev)
local window = require("codetyper.window")
local window = require("codetyper.adapters.nvim.windows")
if window.is_open() then
window.close_split()
end
@@ -397,11 +397,11 @@ function M.check_for_closed_prompt()
if scheduler_enabled then
-- Event-driven: emit to queue
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")
local queue = require("codetyper.core.events.queue")
local patch_mod = require("codetyper.core.diff.patch")
local intent_mod = require("codetyper.core.intent")
local scope_mod = require("codetyper.core.scope")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
-- Open logs panel to show progress
logs_panel.ensure_open()
@@ -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.core.scheduler.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.core.events.queue")
local patch_mod = require("codetyper.core.diff.patch")
local intent_mod = require("codetyper.core.intent")
local scope_mod = require("codetyper.core.scope")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
-- Open logs panel to show progress
logs_panel.ensure_open()
-- 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,153 +720,14 @@ 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
--- Check for closed prompt with preference check
--- If user hasn't chosen auto/manual mode, ask them first
function M.check_for_closed_prompt_with_preference()
local preferences = require("codetyper.preferences")
local preferences = require("codetyper.config.preferences")
local parser = require("codetyper.parser")
-- First check if there are any prompts to process
@@ -749,7 +767,7 @@ end
--- Check all prompts with preference check
function M.check_all_prompts_with_preference()
local preferences = require("codetyper.preferences")
local preferences = require("codetyper.config.preferences")
local parser = require("codetyper.parser")
-- First check if there are any prompts to process
@@ -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
@@ -819,7 +840,7 @@ local auto_opened_buffers = {}
--- Auto-open target file when a coder file is opened directly
function M.auto_open_target_file()
local window = require("codetyper.window")
local window = require("codetyper.adapters.nvim.windows")
-- Skip if split is already open
if window.is_open() then
@@ -1487,7 +1508,7 @@ function M.open_coder_companion(open_split)
if open_split then
-- Use the window module to open split view
local window = require("codetyper.window")
local window = require("codetyper.adapters.nvim.windows")
window.open_split(coder_path, filepath)
else
-- Just open the coder file

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

@@ -2,8 +2,8 @@
local M = {}
local utils = require("codetyper.utils")
local window = require("codetyper.window")
local utils = require("codetyper.support.utils")
local window = require("codetyper.adapters.nvim.windows")
--- Open coder view for current file or select one
---@param opts? table Command options
@@ -108,7 +108,7 @@ end
--- Process prompt at cursor and generate code
local function cmd_process()
local parser = require("codetyper.parser")
local llm = require("codetyper.llm")
local llm = require("codetyper.core.llm")
local bufnr = vim.api.nvim_get_current_buf()
local current_file = vim.fn.expand("%:p")
@@ -153,7 +153,7 @@ end
local function cmd_status()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local tree = require("codetyper.tree")
local tree = require("codetyper.support.tree")
local stats = tree.get_stats()
@@ -195,7 +195,7 @@ end
--- Refresh tree.log manually
local function cmd_tree()
local tree = require("codetyper.tree")
local tree = require("codetyper.support.tree")
if tree.update_tree_log() then
utils.notify("Tree log updated: " .. tree.get_tree_log_path())
else
@@ -205,7 +205,7 @@ end
--- Open tree.log file
local function cmd_tree_view()
local tree = require("codetyper.tree")
local tree = require("codetyper.support.tree")
local tree_log_path = tree.get_tree_log_path()
if not tree_log_path then
@@ -224,61 +224,63 @@ end
--- Reset processed prompts to allow re-processing
local function cmd_reset()
local autocmds = require("codetyper.autocmds")
local autocmds = require("codetyper.adapters.nvim.autocmds")
autocmds.reset_processed()
end
--- Force update gitignore
local function cmd_gitignore()
local gitignore = require("codetyper.gitignore")
local gitignore = require("codetyper.support.gitignore")
gitignore.force_update()
end
--- Open ask panel
local function cmd_ask()
local ask = require("codetyper.ask")
ask.open()
--- Open ask panel (with optional visual selection)
---@param selection table|nil Visual selection info
local function cmd_ask(selection)
local ask = require("codetyper.features.ask.engine")
ask.open(selection)
end
--- Close ask panel
local function cmd_ask_close()
local ask = require("codetyper.ask")
local ask = require("codetyper.features.ask.engine")
ask.close()
end
--- Toggle ask panel
local function cmd_ask_toggle()
local ask = require("codetyper.ask")
local ask = require("codetyper.features.ask.engine")
ask.toggle()
end
--- Clear ask history
local function cmd_ask_clear()
local ask = require("codetyper.ask")
local ask = require("codetyper.features.ask.engine")
ask.clear_history()
end
--- Open agent panel
local function cmd_agent()
local agent_ui = require("codetyper.agent.ui")
agent_ui.open()
--- Open agent panel (with optional visual selection)
---@param selection table|nil Visual selection info
local function cmd_agent(selection)
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
agent_ui.open(selection)
end
--- Close agent panel
local function cmd_agent_close()
local agent_ui = require("codetyper.agent.ui")
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
agent_ui.close()
end
--- Toggle agent panel
local function cmd_agent_toggle()
local agent_ui = require("codetyper.agent.ui")
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
agent_ui.toggle()
end
--- Stop running agent
local function cmd_agent_stop()
local agent = require("codetyper.agent")
local agent = require("codetyper.features.agents")
if agent.is_running() then
agent.stop()
utils.notify("Agent stopped")
@@ -291,9 +293,9 @@ end
---@param task string The task to accomplish
---@param agent_name? string Optional agent name
local function cmd_agentic_run(task, agent_name)
local agentic = require("codetyper.agent.agentic")
local logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local agentic = require("codetyper.features.agents.engine")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
local logs = require("codetyper.adapters.nvim.ui.logs")
-- Open logs panel
logs_panel.open()
@@ -353,7 +355,7 @@ end
--- List available agents
local function cmd_agentic_list()
local agentic = require("codetyper.agent.agentic")
local agentic = require("codetyper.features.agents.engine")
local agents = agentic.list_agents()
local lines = {
@@ -377,7 +379,7 @@ end
--- Initialize .coder/agents/ and .coder/rules/ directories
local function cmd_agentic_init()
local agentic = require("codetyper.agent.agentic")
local agentic = require("codetyper.features.agents.engine")
agentic.init()
local agents_dir = vim.fn.getcwd() .. "/.coder/agents"
@@ -407,14 +409,14 @@ end
--- Toggle logs panel
local function cmd_logs_toggle()
local logs_panel = require("codetyper.logs_panel")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
logs_panel.toggle()
end
--- Show scheduler status and queue info
local function cmd_queue_status()
local scheduler = require("codetyper.agent.scheduler")
local queue = require("codetyper.agent.queue")
local scheduler = require("codetyper.core.scheduler.scheduler")
local queue = require("codetyper.core.events.queue")
local parser = require("codetyper.parser")
local status = scheduler.status()
@@ -453,8 +455,8 @@ end
--- Manually trigger queue processing for current buffer
local function cmd_queue_process()
local autocmds = require("codetyper.autocmds")
local logs_panel = require("codetyper.logs_panel")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
-- Open logs panel to show progress
logs_panel.open()
@@ -482,11 +484,12 @@ 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 logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
local logs = require("codetyper.adapters.nvim.ui.logs")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
@@ -506,115 +509,27 @@ 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 logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
local logs = require("codetyper.adapters.nvim.ui.logs")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
@@ -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
@@ -738,7 +579,7 @@ end
--- Index the entire project
local function cmd_index_project()
local indexer = require("codetyper.indexer")
local indexer = require("codetyper.features.indexer")
utils.notify("Indexing project...", vim.log.levels.INFO)
@@ -760,8 +601,8 @@ end
--- Show index status
local function cmd_index_status()
local indexer = require("codetyper.indexer")
local memory = require("codetyper.indexer.memory")
local indexer = require("codetyper.features.indexer")
local memory = require("codetyper.features.indexer.memory")
local status = indexer.get_status()
local mem_stats = memory.get_stats()
@@ -798,7 +639,7 @@ end
--- Show learned memories
local function cmd_memories()
local memory = require("codetyper.indexer.memory")
local memory = require("codetyper.features.indexer.memory")
local all = memory.get_all()
local lines = {
@@ -843,7 +684,7 @@ end
--- Clear memories
---@param pattern string|nil Optional pattern to match
local function cmd_forget(pattern)
local memory = require("codetyper.indexer.memory")
local memory = require("codetyper.features.indexer.memory")
if not pattern or pattern == "" then
-- Confirm before clearing all
@@ -862,11 +703,12 @@ 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 logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
local logs = require("codetyper.adapters.nvim.ui.logs")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
@@ -888,77 +730,18 @@ 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
---@param args table Command arguments
--- Show LLM accuracy statistics
local function cmd_llm_stats()
local llm = require("codetyper.llm")
local llm = require("codetyper.core.llm")
local stats = llm.get_accuracy_stats()
local lines = {
@@ -986,13 +769,13 @@ end
--- Report feedback on last LLM response
---@param was_good boolean Whether the response was good
local function cmd_llm_feedback(was_good)
local llm = require("codetyper.llm")
local llm = require("codetyper.core.llm")
-- Get the last used provider from logs or default
local provider = "ollama" -- Default assumption
-- Try to get actual last provider from logs
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local entries = logs.get(10)
for i = #entries, 1, -1 do
local entry = entries[i]
@@ -1010,7 +793,7 @@ end
--- Reset LLM accuracy statistics
local function cmd_llm_reset_stats()
local selector = require("codetyper.llm.selector")
local selector = require("codetyper.core.llm.selector")
selector.reset_accuracy_stats()
utils.notify("LLM accuracy statistics reset", vim.log.levels.INFO)
end
@@ -1061,11 +844,11 @@ local function coder_cmd(args)
cmd_forget(args.fargs[2])
end,
["auto-toggle"] = function()
local preferences = require("codetyper.preferences")
local preferences = require("codetyper.config.preferences")
preferences.toggle_auto_process()
end,
["auto-set"] = function(args)
local preferences = require("codetyper.preferences")
local preferences = require("codetyper.config.preferences")
local arg = (args[1] or ""):lower()
if arg == "auto" or arg == "automatic" or arg == "on" then
preferences.set_auto_process(true)
@@ -1094,11 +877,11 @@ local function coder_cmd(args)
["llm-reset-stats"] = cmd_llm_reset_stats,
-- Cost tracking commands
["cost"] = function()
local cost = require("codetyper.cost")
local cost = require("codetyper.core.cost")
cost.toggle()
end,
["cost-clear"] = function()
local cost = require("codetyper.cost")
local cost = require("codetyper.core.cost")
cost.clear()
end,
-- Credentials management commands
@@ -1118,6 +901,27 @@ local function coder_cmd(args)
local credentials = require("codetyper.credentials")
credentials.interactive_switch_provider()
end,
["model"] = function(args)
local credentials = require("codetyper.credentials")
local codetyper = require("codetyper")
local config = codetyper.get_config()
local provider = config.llm.provider
-- Only available for Copilot provider
if provider ~= "copilot" then
utils.notify("CoderModel is only available when using Copilot provider. Current: " .. provider:upper(), vim.log.levels.WARN)
return
end
local model_arg = args.fargs[2]
if model_arg and model_arg ~= "" then
local cost = credentials.get_copilot_model_cost(model_arg) or "custom"
credentials.set_credentials("copilot", { model = model_arg, configured = true })
utils.notify("Copilot model set to: " .. model_arg .. "" .. cost, vim.log.levels.INFO)
else
credentials.interactive_copilot_config(true)
end
end,
}
local cmd_fn = commands[subcommand]
@@ -1146,7 +950,7 @@ function M.setup()
"auto-toggle", "auto-set",
"llm-stats", "llm-feedback-good", "llm-feedback-bad", "llm-reset-stats",
"cost", "cost-clear",
"add-api-key", "remove-api-key", "credentials", "switch-provider",
"add-api-key", "remove-api-key", "credentials", "switch-provider", "model",
}
end,
desc = "Codetyper.nvim commands",
@@ -1178,9 +982,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 +1015,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()
@@ -1255,7 +1069,7 @@ function M.setup()
-- Index command - open coder companion for current file
vim.api.nvim_create_user_command("CoderIndex", function()
local autocmds = require("codetyper.autocmds")
local autocmds = require("codetyper.adapters.nvim.autocmds")
autocmds.open_coder_companion()
end, { desc = "Open coder companion for current file" })
@@ -1290,12 +1104,12 @@ function M.setup()
-- Preferences commands
vim.api.nvim_create_user_command("CoderAutoToggle", function()
local preferences = require("codetyper.preferences")
local preferences = require("codetyper.config.preferences")
preferences.toggle_auto_process()
end, { desc = "Toggle automatic/manual prompt processing" })
vim.api.nvim_create_user_command("CoderAutoSet", function(opts)
local preferences = require("codetyper.preferences")
local preferences = require("codetyper.config.preferences")
local arg = opts.args:lower()
if arg == "auto" or arg == "automatic" or arg == "on" then
preferences.set_auto_process(true)
@@ -1323,7 +1137,7 @@ function M.setup()
-- Brain feedback command - teach the brain from your experience
vim.api.nvim_create_user_command("CoderFeedback", function(opts)
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if not brain.is_initialized() then
vim.notify("Brain not initialized", vim.log.levels.WARN)
return
@@ -1383,7 +1197,7 @@ function M.setup()
-- Brain stats command
vim.api.nvim_create_user_command("CoderBrain", function(opts)
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if not brain.is_initialized() then
vim.notify("Brain not initialized", vim.log.levels.WARN)
return
@@ -1437,7 +1251,7 @@ function M.setup()
-- Cost estimation command
vim.api.nvim_create_user_command("CoderCost", function()
local cost = require("codetyper.cost")
local cost = require("codetyper.core.cost")
cost.toggle()
end, { desc = "Show LLM cost estimation window" })
@@ -1462,6 +1276,182 @@ function M.setup()
credentials.interactive_switch_provider()
end, { desc = "Switch active LLM provider" })
-- Quick model switcher command (Copilot only)
vim.api.nvim_create_user_command("CoderModel", function(opts)
local credentials = require("codetyper.credentials")
local codetyper = require("codetyper")
local config = codetyper.get_config()
local provider = config.llm.provider
-- Only available for Copilot provider
if provider ~= "copilot" then
utils.notify("CoderModel is only available when using Copilot provider. Current: " .. provider:upper(), vim.log.levels.WARN)
return
end
-- If an argument is provided, set the model directly
if opts.args and opts.args ~= "" then
local cost = credentials.get_copilot_model_cost(opts.args) or "custom"
credentials.set_credentials("copilot", { model = opts.args, configured = true })
utils.notify("Copilot model set to: " .. opts.args .. "" .. cost, vim.log.levels.INFO)
return
end
-- Show interactive selector with costs (silent mode - no OAuth message)
credentials.interactive_copilot_config(true)
end, {
nargs = "?",
desc = "Quick switch Copilot model (only available with Copilot provider)",
complete = function()
local codetyper = require("codetyper")
local credentials = require("codetyper.credentials")
local config = codetyper.get_config()
if config.llm.provider == "copilot" then
return credentials.get_copilot_model_names()
end
return {}
end,
})
-- Conflict mode commands
vim.api.nvim_create_user_command("CoderConflictToggle", function()
local patch = require("codetyper.core.diff.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.core.diff.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.core.diff.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.core.diff.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.core.diff.conflict")
local patch = require("codetyper.core.diff.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.core.diff.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.core.diff.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.core.diff.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.core.diff.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.core.diff.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.core.diff.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.core.diff.conflict")
conflict.setup()
-- Linter validation commands
vim.api.nvim_create_user_command("CoderLintCheck", function()
local linter = require("codetyper.features.agents.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.features.agents.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.features.agents.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.core.diff.conflict")
local linter = require("codetyper.features.agents.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

@@ -4,9 +4,9 @@
local M = {}
local agent = require("codetyper.agent")
local logs = require("codetyper.agent.logs")
local utils = require("codetyper.utils")
local agent = require("codetyper.features.agents")
local logs = require("codetyper.adapters.nvim.ui.logs")
local utils = require("codetyper.support.utils")
---@class AgentUIState
---@field chat_buf number|nil Chat buffer
@@ -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.adapters.nvim.ui.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)
@@ -349,7 +387,7 @@ local function submit_input()
current_file = vim.fn.expand("%:p")
end
local llm = require("codetyper.llm")
local llm = require("codetyper.core.llm")
local context = {}
if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then
@@ -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

@@ -0,0 +1,381 @@
---@mod codetyper.agent.context_modal Modal for additional context input
---@brief [[
--- Opens a floating window for user to provide additional context
--- when the LLM requests more information.
---@brief ]]
local M = {}
---@class ContextModalState
---@field buf number|nil Buffer number
---@field win number|nil Window number
---@field original_event table|nil Original prompt event
---@field callback function|nil Callback with additional context
---@field llm_response string|nil LLM's response asking for context
local state = {
buf = nil,
win = nil,
original_event = nil,
callback = nil,
llm_response = nil,
attached_files = nil,
}
--- Close the context modal
function M.close()
if state.win and vim.api.nvim_win_is_valid(state.win) then
vim.api.nvim_win_close(state.win, true)
end
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
vim.api.nvim_buf_delete(state.buf, { force = true })
end
state.win = nil
state.buf = nil
state.original_event = nil
state.callback = nil
state.llm_response = nil
end
--- Submit the additional context
local function submit()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false)
local additional_context = table.concat(lines, "\n")
-- Trim whitespace
additional_context = additional_context:match("^%s*(.-)%s*$") or additional_context
if additional_context == "" then
M.close()
return
end
local original_event = state.original_event
local callback = state.callback
M.close()
if callback and original_event then
-- 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
local ui_prompts = require("codetyper.prompts.agents.modal").ui
vim.api.nvim_buf_set_lines(state.buf, vim.api.nvim_buf_line_count(state.buf), -1, false, ui_prompts.files_header)
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, 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()
state.original_event = original_event
state.llm_response = llm_response
state.callback = callback
-- Calculate window size
local width = math.min(80, vim.o.columns - 10)
local height = 10
-- Create buffer
state.buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.buf].buftype = "nofile"
vim.bo[state.buf].bufhidden = "wipe"
vim.bo[state.buf].filetype = "markdown"
-- Create window
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
state.win = vim.api.nvim_open_win(state.buf, true, {
relative = "editor",
row = row,
col = col,
width = width,
height = height,
style = "minimal",
border = "rounded",
title = " Additional Context Needed ",
title_pos = "center",
})
-- Set window options
vim.wo[state.win].wrap = true
vim.wo[state.win].cursorline = true
local ui_prompts = require("codetyper.prompts.agents.modal").ui
-- Add header showing what the LLM said
local header_lines = {
ui_prompts.llm_response_header,
}
-- Truncate LLM response for display
local response_preview = llm_response or ""
if #response_preview > 200 then
response_preview = response_preview:sub(1, 200) .. "..."
end
for line in response_preview:gmatch("[^\n]+") do
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, ui_prompts.suggested_commands_header)
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, ui_prompts.commands_hint)
end
table.insert(header_lines, "")
table.insert(header_lines, ui_prompts.input_header)
table.insert(header_lines, "")
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, header_lines)
-- Move cursor to the end
vim.api.nvim_win_set_cursor(state.win, { #header_lines, 0 })
-- Set up keymaps
local opts = { buffer = state.buf, noremap = true, silent = true }
-- Submit with Ctrl+Enter or <leader>s
vim.keymap.set("n", "<C-CR>", submit, opts)
vim.keymap.set("i", "<C-CR>", submit, opts)
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)
-- Start in insert mode
vim.cmd("startinsert")
-- Log
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Context modal opened - waiting for user input",
})
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 ui_prompts = require("codetyper.prompts.agents.modal").ui
local insert_pos = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, insert_pos, insert_pos, false, ui_prompts.project_inspect_header)
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()
return state.win ~= nil and vim.api.nvim_win_is_valid(state.win)
end
--- Setup autocmds for the context modal
function M.setup()
local group = vim.api.nvim_create_augroup("CodetypeContextModal", { clear = true })
-- Close context modal when exiting Neovim
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
M.close()
end,
desc = "Close context modal before exiting Neovim",
})
end
return M

View File

@@ -0,0 +1,386 @@
---@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.support.utils")
local prompts = require("codetyper.prompts.agents.diff")
---@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]
local ui_prompts = prompts.review
if not entry then
vim.bo[state.diff_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.diff_buf, 0, -1, false, { ui_prompts.messages.no_changes_short })
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 "~")
local current_status = entry.applied and ui_prompts.status.applied
or (entry.approved and ui_prompts.status.approved or ui_prompts.status.pending)
table.insert(lines, string.format(ui_prompts.diff_header.top,
status_icon, op_icon, vim.fn.fnamemodify(entry.path, ":t")))
table.insert(lines, string.format(ui_prompts.diff_header.path, entry.path))
table.insert(lines, string.format(ui_prompts.diff_header.op, entry.operation))
table.insert(lines, string.format(ui_prompts.diff_header.status, current_status))
table.insert(lines, ui_prompts.diff_header.bottom)
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 ui_prompts = prompts.review
local lines = {}
table.insert(lines, string.format(ui_prompts.list_menu.top, #state.entries))
for _, item in ipairs(ui_prompts.list_menu.items) do
table.insert(lines, item)
end
table.insert(lines, ui_prompts.list_menu.bottom)
table.insert(lines, "")
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, ui_prompts.messages.no_changes)
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(prompts.review.messages.applied_count, 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(prompts.review.messages.no_changes_short, 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,9 @@
local M = {}
local params = require("codetyper.params.agents.logs")
---@class LogEntry
---@field timestamp string ISO timestamp
---@field level string "info" | "debug" | "request" | "response" | "tool" | "error"
@@ -119,14 +122,7 @@ end
---@param status string "start" | "success" | "error" | "approval"
---@param details? string Additional details
function M.tool(tool_name, status, details)
local icons = {
start = "->",
success = "OK",
error = "ERR",
approval = "??",
approved = "YES",
rejected = "NO",
}
local icons = params.icons
local msg = string.format("[%s] %s", icons[status] or status, tool_name)
if details then
@@ -165,10 +161,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,18 +292,22 @@ end
---@param entry LogEntry
---@return string
function M.format_entry(entry)
local level_prefix = ({
info = "i",
debug = ".",
request = ">",
response = "<",
tool = "T",
error = "!",
warning = "?",
success = "i",
queue = "Q",
patch = "P",
})[entry.level] or "?"
-- Claude Code style formatting for thinking/action entries
local thinking_types = params.thinking_types
local is_thinking = vim.tbl_contains(thinking_types, entry.level)
if is_thinking then
local prefix = params.thinking_prefixes[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 = params.level_icons[entry.level] or "?"
local base = string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message)
@@ -248,6 +321,54 @@ 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 = params.thinking_types
if vim.tbl_contains(thinking_types, entry.level) then
local prefix = params.thinking_prefixes[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

@@ -4,8 +4,8 @@
local M = {}
local logs = require("codetyper.agent.logs")
local queue = require("codetyper.agent.queue")
local logs = require("codetyper.adapters.nvim.ui.logs")
local queue = require("codetyper.core.events.queue")
---@class LogsPanelState
---@field buf number|nil Logs buffer

View File

@@ -20,8 +20,8 @@ function M.show()
end
-- Close current panel first
local ask = require("codetyper.ask")
local agent_ui = require("codetyper.agent.ui")
local ask = require("codetyper.features.ask.engine")
local agent_ui = require("codetyper.adapters.nvim.ui.chat")
if ask.is_open() then
ask.close()

View File

@@ -2,7 +2,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
---@type number|nil Current coder window ID
M._coder_win = nil
@@ -43,7 +43,7 @@ function M.open_split(target_path, coder_path)
utils.write_file(coder_path, "")
-- Ensure gitignore is updated when creating a new coder file
local gitignore = require("codetyper.gitignore")
local gitignore = require("codetyper.support.gitignore")
gitignore.ensure_ignored()
end

View File

@@ -1,177 +0,0 @@
---@mod codetyper.agent.context_modal Modal for additional context input
---@brief [[
--- Opens a floating window for user to provide additional context
--- when the LLM requests more information.
---@brief ]]
local M = {}
---@class ContextModalState
---@field buf number|nil Buffer number
---@field win number|nil Window number
---@field original_event table|nil Original prompt event
---@field callback function|nil Callback with additional context
---@field llm_response string|nil LLM's response asking for context
local state = {
buf = nil,
win = nil,
original_event = nil,
callback = nil,
llm_response = nil,
}
--- Close the context modal
function M.close()
if state.win and vim.api.nvim_win_is_valid(state.win) then
vim.api.nvim_win_close(state.win, true)
end
if state.buf and vim.api.nvim_buf_is_valid(state.buf) then
vim.api.nvim_buf_delete(state.buf, { force = true })
end
state.win = nil
state.buf = nil
state.original_event = nil
state.callback = nil
state.llm_response = nil
end
--- Submit the additional context
local function submit()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false)
local additional_context = table.concat(lines, "\n")
-- Trim whitespace
additional_context = additional_context:match("^%s*(.-)%s*$") or additional_context
if additional_context == "" then
M.close()
return
end
local original_event = state.original_event
local callback = state.callback
M.close()
if callback and original_event then
callback(original_event, additional_context)
end
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)
-- Close any existing modal
M.close()
state.original_event = original_event
state.llm_response = llm_response
state.callback = callback
-- Calculate window size
local width = math.min(80, vim.o.columns - 10)
local height = 10
-- Create buffer
state.buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.buf].buftype = "nofile"
vim.bo[state.buf].bufhidden = "wipe"
vim.bo[state.buf].filetype = "markdown"
-- Create window
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
state.win = vim.api.nvim_open_win(state.buf, true, {
relative = "editor",
row = row,
col = col,
width = width,
height = height,
style = "minimal",
border = "rounded",
title = " Additional Context Needed ",
title_pos = "center",
})
-- Set window options
vim.wo[state.win].wrap = true
vim.wo[state.win].cursorline = true
-- Add header showing what the LLM said
local header_lines = {
"-- LLM Response: --",
}
-- Truncate LLM response for display
local response_preview = llm_response or ""
if #response_preview > 200 then
response_preview = response_preview:sub(1, 200) .. "..."
end
for line in response_preview:gmatch("[^\n]+") do
table.insert(header_lines, "-- " .. line)
end
table.insert(header_lines, "")
table.insert(header_lines, "-- Enter additional context below (Ctrl-Enter to submit, Esc to cancel) --")
table.insert(header_lines, "")
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, header_lines)
-- Move cursor to the end
vim.api.nvim_win_set_cursor(state.win, { #header_lines, 0 })
-- Set up keymaps
local opts = { buffer = state.buf, noremap = true, silent = true }
-- Submit with Ctrl+Enter or <leader>s
vim.keymap.set("n", "<C-CR>", submit, opts)
vim.keymap.set("i", "<C-CR>", submit, opts)
vim.keymap.set("n", "<leader>s", submit, opts)
vim.keymap.set("n", "<CR><CR>", submit, opts)
-- Close with Esc or q
vim.keymap.set("n", "<Esc>", M.close, opts)
vim.keymap.set("n", "q", M.close, opts)
-- Start in insert mode
vim.cmd("startinsert")
-- Log
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = "Context modal opened - waiting for user input",
})
end)
end
--- Check if modal is open
---@return boolean
function M.is_open()
return state.win ~= nil and vim.api.nvim_win_is_valid(state.win)
end
--- Setup autocmds for the context modal
function M.setup()
local group = vim.api.nvim_create_augroup("CodetypeContextModal", { clear = true })
-- Close context modal when exiting Neovim
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
M.close()
end,
desc = "Close context modal before exiting Neovim",
})
end
return M

View File

@@ -1,312 +0,0 @@
---@mod codetyper.agent.intent Intent detection from prompts
---@brief [[
--- Parses prompt content to determine user intent and target scope.
--- Intents determine how the generated code should be applied.
---@brief ]]
local M = {}
---@class Intent
---@field type string "complete"|"refactor"|"add"|"fix"|"document"|"test"|"explain"|"optimize"
---@field scope_hint string|nil "function"|"class"|"block"|"file"|"selection"|nil
---@field confidence number 0.0-1.0 how confident we are about the intent
---@field action string "replace"|"insert"|"append"|"none"
---@field keywords string[] Keywords that triggered this intent
--- Intent patterns with associated metadata
local intent_patterns = {
-- Complete: fill in missing implementation
complete = {
patterns = {
"complete",
"finish",
"implement",
"fill in",
"fill out",
"stub",
"todo",
"fixme",
},
scope_hint = "function",
action = "replace",
priority = 1,
},
-- Refactor: rewrite existing code
refactor = {
patterns = {
"refactor",
"rewrite",
"restructure",
"reorganize",
"clean up",
"cleanup",
"simplify",
"improve",
},
scope_hint = "function",
action = "replace",
priority = 2,
},
-- Fix: repair bugs or issues
fix = {
patterns = {
"fix",
"repair",
"correct",
"debug",
"solve",
"resolve",
"patch",
"bug",
"error",
"issue",
},
scope_hint = "function",
action = "replace",
priority = 1,
},
-- Add: insert new code
add = {
patterns = {
"add",
"create",
"insert",
"include",
"append",
"new",
"generate",
"write",
},
scope_hint = nil, -- Could be anywhere
action = "insert",
priority = 3,
},
-- Document: add documentation
document = {
patterns = {
"document",
"comment",
"jsdoc",
"docstring",
"describe",
"annotate",
"type hint",
"typehint",
},
scope_hint = "function",
action = "replace", -- Replace with documented version
priority = 2,
},
-- Test: generate tests
test = {
patterns = {
"test",
"spec",
"unit test",
"integration test",
"coverage",
},
scope_hint = "file",
action = "append",
priority = 3,
},
-- Optimize: improve performance
optimize = {
patterns = {
"optimize",
"performance",
"faster",
"efficient",
"speed up",
"reduce",
"minimize",
},
scope_hint = "function",
action = "replace",
priority = 2,
},
-- Explain: provide explanation (no code change)
explain = {
patterns = {
"explain",
"what does",
"how does",
"why",
"describe",
"walk through",
"understand",
},
scope_hint = "function",
action = "none",
priority = 4,
},
}
--- Scope hint patterns
local scope_patterns = {
["this function"] = "function",
["this method"] = "function",
["the function"] = "function",
["the method"] = "function",
["this class"] = "class",
["the class"] = "class",
["this file"] = "file",
["the file"] = "file",
["this block"] = "block",
["the block"] = "block",
["this"] = nil, -- Use Tree-sitter to determine
["here"] = nil,
}
--- Detect intent from prompt content
---@param prompt string The prompt content
---@return Intent
function M.detect(prompt)
local lower = prompt:lower()
local best_match = nil
local best_priority = 999
local matched_keywords = {}
-- Check each intent type
for intent_type, config in pairs(intent_patterns) do
for _, pattern in ipairs(config.patterns) do
if lower:find(pattern, 1, true) then
if config.priority < best_priority then
best_match = intent_type
best_priority = config.priority
matched_keywords = { pattern }
elseif config.priority == best_priority and best_match == intent_type then
table.insert(matched_keywords, pattern)
end
end
end
end
-- Default to "add" if no clear intent
if not best_match then
best_match = "add"
matched_keywords = {}
end
local config = intent_patterns[best_match]
-- Detect scope hint from prompt
local scope_hint = config.scope_hint
for pattern, hint in pairs(scope_patterns) do
if lower:find(pattern, 1, true) then
scope_hint = hint or scope_hint
break
end
end
-- Calculate confidence based on keyword matches
local confidence = 0.5 + (#matched_keywords * 0.15)
confidence = math.min(confidence, 1.0)
return {
type = best_match,
scope_hint = scope_hint,
confidence = confidence,
action = config.action,
keywords = matched_keywords,
}
end
--- Check if intent requires code modification
---@param intent Intent
---@return boolean
function M.modifies_code(intent)
return intent.action ~= "none"
end
--- Check if intent should replace existing code
---@param intent Intent
---@return boolean
function M.is_replacement(intent)
return intent.action == "replace"
end
--- Check if intent adds new code
---@param intent Intent
---@return boolean
function M.is_insertion(intent)
return intent.action == "insert" or intent.action == "append"
end
--- Get system prompt modifier based on intent
---@param intent Intent
---@return string
function M.get_prompt_modifier(intent)
local modifiers = {
complete = [[
You are completing an incomplete function.
Return the complete function with all missing parts filled in.
Keep the existing signature unless changes are required.
Output only the code, no explanations.]],
refactor = [[
You are refactoring existing code.
Improve the code structure while maintaining the same behavior.
Keep the function signature unchanged.
Output only the refactored code, no explanations.]],
fix = [[
You are fixing a bug in the code.
Identify and correct the issue while minimizing changes.
Preserve the original intent of the code.
Output only the fixed code, no explanations.]],
add = [[
You are adding new code.
Follow the existing code style and conventions.
Output only the new code to be inserted, no explanations.]],
document = [[
You are adding documentation to the code.
Add appropriate comments/docstrings for the function.
Include parameter types, return types, and description.
Output the complete function with documentation.]],
test = [[
You are generating tests for the code.
Create comprehensive unit tests covering edge cases.
Follow the testing conventions of the project.
Output only the test code, no explanations.]],
optimize = [[
You are optimizing code for performance.
Improve efficiency while maintaining correctness.
Document any significant algorithmic changes.
Output only the optimized code, no explanations.]],
explain = [[
You are explaining code to a developer.
Provide a clear, concise explanation of what the code does.
Include information about the algorithm and any edge cases.
Do not output code, only explanation.]],
}
return modifiers[intent.type] or modifiers.add
end
--- Format intent for logging
---@param intent Intent
---@return string
function M.format(intent)
return string.format(
"%s (scope: %s, action: %s, confidence: %.2f)",
intent.type,
intent.scope_hint or "auto",
intent.action,
intent.confidence
)
end
return M

View File

@@ -1,222 +0,0 @@
---@mod codetyper.agent.tools Tool definitions for the agent system
---
--- Defines available tools that the LLM can use to interact with files and system.
local M = {}
--- Tool definitions in a provider-agnostic format
M.definitions = {
read_file = {
name = "read_file",
description = "Read the contents of a file at the specified path",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Absolute or relative path to the file to read",
},
},
required = { "path" },
},
},
edit_file = {
name = "edit_file",
description = "Edit a file by replacing specific content. Provide the exact content to find and the replacement.",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Path to the file to edit",
},
find = {
type = "string",
description = "Exact content to find (must match exactly, including whitespace)",
},
replace = {
type = "string",
description = "Content to replace with",
},
},
required = { "path", "find", "replace" },
},
},
write_file = {
name = "write_file",
description = "Write content to a file, creating it if it doesn't exist or overwriting if it does",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Path to the file to write",
},
content = {
type = "string",
description = "Complete file content to write",
},
},
required = { "path", "content" },
},
},
bash = {
name = "bash",
description = "Execute a bash command and return the output. Use for git, npm, build tools, etc.",
parameters = {
type = "object",
properties = {
command = {
type = "string",
description = "The bash command to execute",
},
timeout = {
type = "number",
description = "Timeout in milliseconds (default: 30000)",
},
},
required = { "command" },
},
},
delete_file = {
name = "delete_file",
description = "Delete a file from the filesystem. Use with caution - requires explicit user approval.",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Path to the file to delete",
},
reason = {
type = "string",
description = "Reason for deleting this file (shown to user for approval)",
},
},
required = { "path", "reason" },
},
},
list_directory = {
name = "list_directory",
description = "List files and directories in a path. Use to explore project structure.",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Path to the directory to list (defaults to current directory)",
},
recursive = {
type = "boolean",
description = "Whether to list recursively (default: false, max depth: 3)",
},
},
required = {},
},
},
search_files = {
name = "search_files",
description = "Search for files by name pattern or content. Use to find relevant files in the project.",
parameters = {
type = "object",
properties = {
pattern = {
type = "string",
description = "Glob pattern for file names (e.g., '*.lua', 'test_*.py')",
},
content = {
type = "string",
description = "Search for files containing this text",
},
path = {
type = "string",
description = "Directory to search in (defaults to project root)",
},
},
required = {},
},
},
}
--- Convert tool definitions to Claude API format
---@return table[] Tools in Claude's expected format
function M.to_claude_format()
local tools = {}
for _, tool in pairs(M.definitions) do
table.insert(tools, {
name = tool.name,
description = tool.description,
input_schema = tool.parameters,
})
end
return tools
end
--- Convert tool definitions to OpenAI API format
---@return table[] Tools in OpenAI's expected format
function M.to_openai_format()
local tools = {}
for _, tool in pairs(M.definitions) do
table.insert(tools, {
type = "function",
["function"] = {
name = tool.name,
description = tool.description,
parameters = tool.parameters,
},
})
end
return tools
end
--- Convert tool definitions to prompt format for Ollama
---@return string Formatted tool descriptions for system prompt
function M.to_prompt_format()
local lines = {
"You have access to the following tools. To use a tool, respond with a JSON block.",
"",
}
for _, tool in pairs(M.definitions) do
table.insert(lines, "## " .. tool.name)
table.insert(lines, tool.description)
table.insert(lines, "")
table.insert(lines, "Parameters:")
for prop_name, prop in pairs(tool.parameters.properties) do
local required = vim.tbl_contains(tool.parameters.required or {}, prop_name)
local req_str = required and " (required)" or " (optional)"
table.insert(lines, " - " .. prop_name .. ": " .. prop.description .. req_str)
end
table.insert(lines, "")
end
table.insert(lines, "---")
table.insert(lines, "")
table.insert(lines, "To call a tool, output a JSON block like this:")
table.insert(lines, "```json")
table.insert(lines, '{"tool": "tool_name", "parameters": {"param1": "value1"}}')
table.insert(lines, "```")
table.insert(lines, "")
table.insert(lines, "After receiving tool results, continue your response or call another tool.")
table.insert(lines, "When you're done, just respond normally without any tool calls.")
return table.concat(lines, "\n")
end
--- Get a list of tool names
---@return string[]
function M.get_tool_names()
local names = {}
for name, _ in pairs(M.definitions) do
table.insert(names, name)
end
return names
end
return M

View File

@@ -0,0 +1,18 @@
--- Banned commands for safety
M.BANNED_COMMANDS = {
"rm -rf /",
"rm -rf /*",
"dd if=/dev/zero",
"mkfs",
":(){ :|:& };:",
"> /dev/sda",
}
--- Banned patterns
M.BANNED_PATTERNS = {
"curl.*|.*sh",
"wget.*|.*sh",
"rm%s+%-rf%s+/",
}
return M

View File

@@ -6,7 +6,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Get the credentials file path
---@return string Path to credentials file
@@ -221,10 +221,61 @@ 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 with cost multipliers: 0x = free, 0.33x = discount, 1x = standard, 3x = premium
M.copilot_models = {
-- Free tier (0x)
{ name = "gpt-4.1", cost = "0x" },
{ name = "gpt-4o", cost = "0x" },
{ name = "gpt-5-mini", cost = "0x" },
{ name = "grok-code-fast-1", cost = "0x" },
{ name = "raptor-mini", cost = "0x" },
-- Discount tier (0.33x)
{ name = "claude-haiku-4.5", cost = "0.33x" },
{ name = "gemini-3-flash", cost = "0.33x" },
{ name = "gpt-5.1-codex-mini", cost = "0.33x" },
-- Standard tier (1x)
{ name = "claude-sonnet-4", cost = "1x" },
{ name = "claude-sonnet-4.5", cost = "1x" },
{ name = "gemini-2.5-pro", cost = "1x" },
{ name = "gemini-3-pro", cost = "1x" },
{ name = "gpt-5", cost = "1x" },
{ name = "gpt-5-codex", cost = "1x" },
{ name = "gpt-5.1", cost = "1x" },
{ name = "gpt-5.1-codex", cost = "1x" },
{ name = "gpt-5.1-codex-max", cost = "1x" },
{ name = "gpt-5.2", cost = "1x" },
{ name = "gpt-5.2-codex", cost = "1x" },
-- Premium tier (3x)
{ name = "claude-opus-4.5", cost = "3x" },
}
--- Get list of copilot model names (for completion)
---@return string[]
function M.get_copilot_model_names()
local names = {}
for _, model in ipairs(M.copilot_models) do
table.insert(names, model.name)
end
return names
end
--- Get cost for a copilot model
---@param model_name string
---@return string|nil
function M.get_copilot_model_cost(model_name)
for _, model in ipairs(M.copilot_models) do
if model.name == model_name then
return model.cost
end
end
return nil
end
--- Interactive command to add/update API key
function M.interactive_add()
local providers = { "claude", "openai", "gemini", "copilot", "ollama" }
@@ -277,28 +328,56 @@ function M.interactive_api_key(provider)
end
--- Interactive Copilot configuration (no API key, uses OAuth)
function M.interactive_copilot_config()
utils.notify("Copilot uses OAuth from copilot.lua/copilot.vim - no API key needed", vim.log.levels.INFO)
---@param silent? boolean If true, don't show the OAuth info message
function M.interactive_copilot_config(silent)
if not silent then
utils.notify("Copilot uses OAuth from copilot.lua/copilot.vim - no API key needed", vim.log.levels.INFO)
end
-- 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
local current_cost = M.get_copilot_model_cost(current_model) or "?"
-- Build model options with "Custom..." option
local model_options = vim.deepcopy(M.copilot_models)
table.insert(model_options, { name = "Custom...", cost = "" })
vim.ui.select(model_options, {
prompt = "Select Copilot model (current: " .. current_model .. "" .. current_cost .. "):",
format_item = function(item)
local display = item.name
if item.cost and item.cost ~= "" then
display = display .. "" .. item.cost
end
if item.name == current_model then
display = display .. " [current]"
end
return display
end,
}, function(choice)
if choice == nil then
return -- Cancelled
end
if model == "" then
model = default_model
if choice.name == "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.name,
configured = true,
})
end
M.save_and_notify("copilot", {
model = model,
-- Mark as configured even without API key
configured = true,
})
end)
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

@@ -6,7 +6,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
---@class CoderPreferences
---@field auto_process boolean Whether to auto-process /@ @/ tags (default: nil = ask)

View File

@@ -6,7 +6,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Cost history file name
local COST_HISTORY_FILE = "cost_history.json"
@@ -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 },

File diff suppressed because it is too large Load Diff

View File

@@ -157,17 +157,19 @@ function M.show_diff(diff_data, callback)
end
-- Show help message
vim.api.nvim_echo({
{ "Diff: ", "Normal" },
{ diff_data.path, "Directory" },
{ " | ", "Normal" },
{ "y/<CR>", "Keyword" },
{ " approve ", "Normal" },
{ "n/q/<Esc>", "Keyword" },
{ " reject ", "Normal" },
{ "<Tab>", "Keyword" },
{ " switch panes", "Normal" },
}, false, {})
local help_msg = require("codetyper.prompts.agents.diff").diff_help
-- Iterate to replace {path} variable
local final_help = {}
for _, item in ipairs(help_msg) do
if item[1] == "{path}" then
table.insert(final_help, { diff_data.path, item[2] })
else
table.insert(final_help, item)
end
end
vim.api.nvim_echo(final_help, false, {})
end
---@alias BashApprovalResult {approved: boolean, permission_level: string|nil}
@@ -176,7 +178,7 @@ end
---@param command string The bash command to approve
---@param callback fun(result: BashApprovalResult) Called with user decision
function M.show_bash_approval(command, callback)
local permissions = require("codetyper.agent.permissions")
local permissions = require("codetyper.features.agents.permissions")
-- Check if command is auto-approved
local perm_result = permissions.check_bash_permission(command)
@@ -188,31 +190,31 @@ function M.show_bash_approval(command, callback)
end
-- Create approval dialog with options
local approval_prompts = require("codetyper.prompts.agents.diff").bash_approval
local lines = {
"",
" BASH COMMAND APPROVAL",
" " .. string.rep("", 56),
approval_prompts.title,
approval_prompts.divider,
"",
" Command:",
approval_prompts.command_label,
" $ " .. command,
"",
}
-- Add warning for dangerous commands
if not perm_result.allowed and perm_result.reason ~= "Requires approval" then
table.insert(lines, " ⚠️ WARNING: " .. perm_result.reason)
table.insert(lines, approval_prompts.warning_prefix .. perm_result.reason)
table.insert(lines, "")
end
table.insert(lines, " " .. string.rep("", 56))
table.insert(lines, approval_prompts.divider)
table.insert(lines, "")
table.insert(lines, " [y] Allow once - Execute this command")
table.insert(lines, " [s] Allow this session - Auto-allow until restart")
table.insert(lines, " [a] Add to allow list - Always allow this command")
table.insert(lines, " [n] Reject - Cancel execution")
for _, opt in ipairs(approval_prompts.options) do
table.insert(lines, opt)
end
table.insert(lines, "")
table.insert(lines, " " .. string.rep("", 56))
table.insert(lines, " Press key to choose | [q] or [Esc] to cancel")
table.insert(lines, approval_prompts.divider)
table.insert(lines, approval_prompts.cancel_hint)
table.insert(lines, "")
local width = math.max(65, #command + 15)

View File

@@ -2,16 +2,32 @@
---@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 = {}
local params = require("codetyper.params.agents.patch")
--- Lazy load inject module to avoid circular requires
local function get_inject_module()
return require("codetyper.agent.inject")
return require("codetyper.inject")
end
--- Lazy load search_replace module
local function get_search_replace_module()
return require("codetyper.core.diff.search_replace")
end
--- Lazy load conflict module
local function get_conflict_module()
return require("codetyper.core.diff.conflict")
end
--- Configuration for patch behavior
local config = params.config
---@class BufferSnapshot
---@field bufnr number Buffer number
---@field changedtick number vim.b.changedtick at snapshot time
@@ -27,11 +43,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[]
@@ -152,7 +170,7 @@ function M.queue_patch(patch)
-- Log patch creation
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "patch",
message = string.format(
@@ -194,6 +212,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 +223,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
local intent_mod = require("codetyper.agent.intent")
-- If we have SEARCH/REPLACE blocks, use that strategy
if use_search_replace then
injection_strategy = "search_replace"
pcall(function()
local logs = require("codetyper.adapters.nvim.ui.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.core.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.adapters.nvim.ui.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.adapters.nvim.ui.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 +311,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
@@ -438,7 +510,7 @@ function M.apply(patch)
M.mark_stale(patch.id, stale_reason)
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "warning",
message = string.format("Patch %s is stale: %s", patch.id, stale_reason or "unknown"),
@@ -464,18 +536,20 @@ 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()
if tags_removed > 0 then
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local source_name = vim.api.nvim_buf_get_name(source_bufnr)
logs.add({
type = "info",
@@ -490,6 +564,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.adapters.nvim.ui.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.adapters.nvim.ui.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.core.memory")
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.adapters.nvim.ui.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 +652,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 +706,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.adapters.nvim.ui.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
@@ -570,7 +738,7 @@ function M.apply(patch)
-- Log injection details
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
if inject_result.imports_added > 0 then
logs.add({
type = "info",
@@ -598,7 +766,7 @@ function M.apply(patch)
M.mark_applied(patch.id)
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "success",
message = string.format("Patch %s applied successfully", patch.id),
@@ -612,7 +780,7 @@ function M.apply(patch)
-- Learn from successful code generation - this builds neural pathways
-- The more code is successfully applied, the better the brain becomes
pcall(function()
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if brain.is_initialized() then
-- Learn the successful pattern
local intent_type = patch.intent and patch.intent.type or "unknown"
@@ -729,4 +897,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.adapters.nvim.ui.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.adapters.nvim.ui.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,572 @@
---@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 = {}
local params = require("codetyper.params.agents.search_replace").patterns
---@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(params.dash_style) 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(params.claude_style) 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(params.simple_style) 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(params.diff_block)
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

@@ -194,7 +194,7 @@ function M.enqueue(event)
-- Log to agent logs if available
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "queue",
message = string.format("Event queued: %s (priority: %d)", event.id, event.priority),

View File

@@ -0,0 +1,117 @@
---@mod codetyper.agent.intent Intent detection from prompts
---@brief [[
--- Parses prompt content to determine user intent and target scope.
--- Intents determine how the generated code should be applied.
---@brief ]]
local M = {}
---@class Intent
---@field type string "complete"|"refactor"|"add"|"fix"|"document"|"test"|"explain"|"optimize"
---@field scope_hint string|nil "function"|"class"|"block"|"file"|"selection"|nil
---@field confidence number 0.0-1.0 how confident we are about the intent
---@field action string "replace"|"insert"|"append"|"none"
---@field keywords string[] Keywords that triggered this intent
local params = require("codetyper.params.agents.intent")
local intent_patterns = params.intent_patterns
local scope_patterns = params.scope_patterns
local prompts = require("codetyper.prompts.agents.intent")
--- Detect intent from prompt content
---@param prompt string The prompt content
---@return Intent
function M.detect(prompt)
local lower = prompt:lower()
local best_match = nil
local best_priority = 999
local matched_keywords = {}
-- Check each intent type
for intent_type, config in pairs(intent_patterns) do
for _, pattern in ipairs(config.patterns) do
if lower:find(pattern, 1, true) then
if config.priority < best_priority then
best_match = intent_type
best_priority = config.priority
matched_keywords = { pattern }
elseif config.priority == best_priority and best_match == intent_type then
table.insert(matched_keywords, pattern)
end
end
end
end
-- Default to "add" if no clear intent
if not best_match then
best_match = "add"
matched_keywords = {}
end
local config = intent_patterns[best_match]
-- Detect scope hint from prompt
local scope_hint = config.scope_hint
for pattern, hint in pairs(scope_patterns) do
if lower:find(pattern, 1, true) then
scope_hint = hint or scope_hint
break
end
end
-- Calculate confidence based on keyword matches
local confidence = 0.5 + (#matched_keywords * 0.15)
confidence = math.min(confidence, 1.0)
return {
type = best_match,
scope_hint = scope_hint,
confidence = confidence,
action = config.action,
keywords = matched_keywords,
}
end
--- Check if intent requires code modification
---@param intent Intent
---@return boolean
function M.modifies_code(intent)
return intent.action ~= "none"
end
--- Check if intent should replace existing code
---@param intent Intent
---@return boolean
function M.is_replacement(intent)
return intent.action == "replace"
end
--- Check if intent adds new code
---@param intent Intent
---@return boolean
function M.is_insertion(intent)
return intent.action == "insert" or intent.action == "append"
end
--- Get system prompt modifier based on intent
---@param intent Intent
---@return string
function M.get_prompt_modifier(intent)
local modifiers = prompts.modifiers
return modifiers[intent.type] or modifiers.add
end
--- Format intent for logging
---@param intent Intent
---@return string
function M.format(intent)
return string.format(
"%s (scope: %s, action: %s, confidence: %.2f)",
intent.type,
intent.scope_hint or "auto",
intent.action,
intent.confidence
)
end
return M

View File

@@ -6,41 +6,14 @@
local M = {}
local params = require("codetyper.params.agents.confidence")
--- Heuristic weights (must sum to 1.0)
M.weights = {
length = 0.15, -- Response length relative to prompt
uncertainty = 0.30, -- Uncertainty phrases
syntax = 0.25, -- Syntax completeness
repetition = 0.15, -- Duplicate lines
truncation = 0.15, -- Incomplete ending
}
M.weights = params.weights
--- Uncertainty phrases that indicate low confidence
local uncertainty_phrases = {
-- English
"i'm not sure",
"i am not sure",
"maybe",
"perhaps",
"might work",
"could work",
"not certain",
"uncertain",
"i think",
"possibly",
"TODO",
"FIXME",
"XXX",
"placeholder",
"implement this",
"fill in",
"your code here",
"...", -- Ellipsis as placeholder
"# TODO",
"// TODO",
"-- TODO",
"/* TODO",
}
local uncertainty_phrases = params.uncertainty_phrases
--- Score based on response length relative to prompt
---@param response string
@@ -94,32 +67,6 @@ local function score_uncertainty(response)
end
end
--- Check bracket balance for common languages
---@param response string
---@return boolean balanced
local function check_brackets(response)
local pairs = {
["{"] = "}",
["["] = "]",
["("] = ")",
}
local stack = {}
for char in response:gmatch(".") do
if pairs[char] then
table.insert(stack, pairs[char])
elseif char == "}" or char == "]" or char == ")" then
if #stack == 0 or stack[#stack] ~= char then
return false
end
table.remove(stack)
end
end
return #stack == 0
end
--- Score based on syntax completeness
---@param response string
---@return number 0.0-1.0
@@ -127,7 +74,7 @@ local function score_syntax(response)
local score = 1.0
-- Check bracket balance
if not check_brackets(response) then
if not require("codetyper.support.utils").check_brackets(response) then
score = score - 0.4
end

View File

@@ -2,8 +2,8 @@
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
local utils = require("codetyper.support.utils")
local llm = require("codetyper.core.llm")
--- Copilot API endpoints
local AUTH_URL = "https://api.github.com/copilot_internal/v2/token"
@@ -296,7 +296,7 @@ local function make_request(token, body, callback)
-- Record usage for cost tracking
if usage.prompt_tokens or usage.completion_tokens then
local cost = require("codetyper.cost")
local cost = require("codetyper.core.cost")
cost.record_usage(
get_model(),
usage.prompt_tokens or 0,
@@ -348,7 +348,7 @@ end
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil)
function M.generate(prompt, context, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
ensure_initialized()
@@ -414,7 +414,7 @@ end
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil)
function M.generate_with_tools(messages, context, tool_definitions, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
ensure_initialized()
@@ -436,13 +436,12 @@ function M.generate_with_tools(messages, context, tool_definitions, callback)
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
local tools_module = require("codetyper.core.tools")
local agent_prompts = require("codetyper.prompts.agents")
-- 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")
@@ -622,7 +634,7 @@ function M.generate_with_tools(messages, context, tool_definitions, callback)
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
-- Record usage for cost tracking
local cost_tracker = require("codetyper.cost")
local cost_tracker = require("codetyper.core.cost")
cost_tracker.record_usage(
get_model(),
response.usage.prompt_tokens or 0,

View File

@@ -2,8 +2,8 @@
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
local utils = require("codetyper.support.utils")
local llm = require("codetyper.core.llm")
--- Gemini API endpoint
local API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
@@ -167,7 +167,7 @@ end
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local model = get_model()
-- Log the request
@@ -217,7 +217,7 @@ end
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil) Callback with raw response
function M.generate_with_tools(messages, context, tool_definitions, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local model = get_model()
logs.request("gemini", model)
@@ -230,8 +230,8 @@ function M.generate_with_tools(messages, context, tool_definitions, callback)
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
local tools_module = require("codetyper.core.tools")
local agent_prompts = require("codetyper.prompts.agents")
-- Build system prompt with agent instructions
local system_prompt = llm.build_system_prompt(context)

View File

@@ -1,8 +1,8 @@
---@mod codetyper.llm LLM interface for Codetyper.nvim
local M = {}
local lang_map = require("codetyper.utils.langmap")
local utils = require("codetyper.utils")
local lang_map = require("codetyper.support.langmap")
local utils = require("codetyper.support.utils")
--- Get the appropriate LLM client based on configuration
---@return table LLM client module
@@ -11,13 +11,13 @@ function M.get_client()
local config = codetyper.get_config()
if config.llm.provider == "ollama" then
return require("codetyper.llm.ollama")
return require("codetyper.core.llm.ollama")
elseif config.llm.provider == "openai" then
return require("codetyper.llm.openai")
return require("codetyper.core.llm.openai")
elseif config.llm.provider == "gemini" then
return require("codetyper.llm.gemini")
return require("codetyper.core.llm.gemini")
elseif config.llm.provider == "copilot" then
return require("codetyper.llm.copilot")
return require("codetyper.core.llm.copilot")
else
error("Unknown LLM provider: " .. config.llm.provider)
end
@@ -39,14 +39,14 @@ end
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil, metadata: table|nil) Callback
function M.smart_generate(prompt, context, callback)
local selector = require("codetyper.llm.selector")
local selector = require("codetyper.core.llm.selector")
selector.smart_generate(prompt, context, callback)
end
--- Get accuracy statistics for providers
---@return table Statistics for each provider
function M.get_accuracy_stats()
local selector = require("codetyper.llm.selector")
local selector = require("codetyper.core.llm.selector")
return selector.get_accuracy_stats()
end
@@ -54,7 +54,7 @@ end
---@param provider string Which provider generated the response
---@param was_correct boolean Whether the response was good
function M.report_feedback(provider, was_correct)
local selector = require("codetyper.llm.selector")
local selector = require("codetyper.core.llm.selector")
selector.report_feedback(provider, was_correct)
end

View File

@@ -2,8 +2,8 @@
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
local utils = require("codetyper.support.utils")
local llm = require("codetyper.core.llm")
--- Get Ollama host from stored credentials or config
---@return string Host URL
@@ -137,7 +137,7 @@ end
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local model = get_model()
-- Log the request
@@ -217,9 +217,9 @@ end
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil) Callback with Claude-like response format
function M.generate_with_tools(messages, context, tool_definitions, callback)
local logs = require("codetyper.agent.logs")
local agent_prompts = require("codetyper.prompts.agent")
local tools_module = require("codetyper.agent.tools")
local logs = require("codetyper.adapters.nvim.ui.logs")
local agent_prompts = require("codetyper.prompts.agents")
local tools_module = require("codetyper.core.tools")
logs.request("ollama", get_model())
logs.thinking("Preparing agent request...")
@@ -322,7 +322,7 @@ function M.generate_with_tools(messages, context, tool_definitions, callback)
logs.response(response.prompt_eval_count or 0, response.eval_count or 0, "stop")
-- Record usage for cost tracking (free for local models)
local cost = require("codetyper.cost")
local cost = require("codetyper.core.cost")
cost.record_usage(
get_model(),
response.prompt_eval_count or 0,

View File

@@ -2,8 +2,8 @@
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
local utils = require("codetyper.support.utils")
local llm = require("codetyper.core.llm")
--- OpenAI API endpoint
local API_URL = "https://api.openai.com/v1/chat/completions"
@@ -158,7 +158,7 @@ end
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local model = get_model()
-- Log the request
@@ -208,7 +208,7 @@ end
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil) Callback with raw response
function M.generate_with_tools(messages, context, tool_definitions, callback)
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local model = get_model()
logs.request("openai", model)
@@ -221,8 +221,8 @@ function M.generate_with_tools(messages, context, tool_definitions, callback)
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
local tools_module = require("codetyper.core.tools")
local agent_prompts = require("codetyper.prompts.agents")
-- Build system prompt with agent instructions
local system_prompt = llm.build_system_prompt(context)
@@ -310,7 +310,7 @@ function M.generate_with_tools(messages, context, tool_definitions, callback)
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
-- Record usage for cost tracking
local cost = require("codetyper.cost")
local cost = require("codetyper.core.cost")
cost.record_usage(
model,
response.usage.prompt_tokens or 0,

View File

@@ -4,6 +4,9 @@
local M = {}
local params = require("codetyper.params.agents.parser")
---@class ParsedResponse
---@field text string Text content from the response
---@field tool_calls ToolCall[] List of tool calls
@@ -48,11 +51,11 @@ function M.parse_ollama_response(response_text)
local result = {
text = response_text,
tool_calls = {},
stop_reason = "end_turn",
stop_reason = params.defaults.stop_reason,
}
-- Pattern to find JSON tool blocks in fenced code blocks
local fenced_pattern = "```json%s*(%b{})%s*```"
local fenced_pattern = params.patterns.fenced_json
-- Find all fenced JSON blocks
for json_str in response_text:gmatch(fenced_pattern) do
@@ -63,14 +66,14 @@ function M.parse_ollama_response(response_text)
name = parsed.tool,
parameters = parsed.parameters,
})
result.stop_reason = "tool_use"
result.stop_reason = params.defaults.tool_stop_reason
end
end
-- Also try to find inline JSON (not in code blocks)
-- Pattern for {"tool": "...", "parameters": {...}}
if #result.tool_calls == 0 then
local inline_pattern = '(%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%})'
local inline_pattern = params.patterns.inline_json
for json_str in response_text:gmatch(inline_pattern) do
local ok, parsed = pcall(vim.json.decode, json_str)
if ok and parsed.tool and parsed.parameters then
@@ -79,15 +82,15 @@ function M.parse_ollama_response(response_text)
name = parsed.tool,
parameters = parsed.parameters,
})
result.stop_reason = "tool_use"
result.stop_reason = params.defaults.tool_stop_reason
end
end
end
-- Clean tool JSON from displayed text
if #result.tool_calls > 0 then
result.text = result.text:gsub("```json%s*%b{}%s*```", "[Tool call]")
result.text = result.text:gsub('%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%}', "[Tool call]")
result.text = result.text:gsub(params.patterns.fenced_json, params.defaults.replacement_text)
result.text = result.text:gsub(params.patterns.inline_json, params.defaults.replacement_text)
end
return result

View File

@@ -296,7 +296,7 @@ end
---@param callback fun(result: PonderResult) Callback with pondering result
function M.ponder(prompt, context, ollama_response, callback)
-- Use Copilot as verifier
local copilot = require("codetyper.llm.copilot")
local copilot = require("codetyper.core.llm.copilot")
-- Build verification prompt
local verify_prompt = prompt
@@ -393,7 +393,7 @@ function M.smart_generate(prompt, context, callback)
-- Log selection
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format(
@@ -408,9 +408,9 @@ function M.smart_generate(prompt, context, callback)
-- Get the selected client
local client
if selection.provider == "ollama" then
client = require("codetyper.llm.ollama")
client = require("codetyper.core.llm.ollama")
else
client = require("codetyper.llm.copilot")
client = require("codetyper.core.llm.copilot")
end
-- Generate response
@@ -419,7 +419,7 @@ function M.smart_generate(prompt, context, callback)
-- Fallback on error
if selection.provider == "ollama" then
-- Try Copilot as fallback
local copilot = require("codetyper.llm.copilot")
local copilot = require("codetyper.core.llm.copilot")
copilot.generate(prompt, context, function(fallback_response, fallback_error)
callback(fallback_response, fallback_error, {
provider = "copilot",

View File

@@ -1,10 +1,10 @@
--- Brain Delta Commit Operations
--- Git-like commit creation and management
local storage = require("codetyper.brain.storage")
local hash_mod = require("codetyper.brain.hash")
local diff_mod = require("codetyper.brain.delta.diff")
local types = require("codetyper.brain.types")
local storage = require("codetyper.core.memory.storage")
local hash_mod = require("codetyper.core.memory.hash")
local diff_mod = require("codetyper.core.memory.delta.diff")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,7 +1,7 @@
--- Brain Delta Diff Computation
--- Field-level diff algorithms for delta versioning
local hash = require("codetyper.brain.hash")
local hash = require("codetyper.core.memory.hash")
local M = {}

View File

@@ -1,10 +1,10 @@
--- Brain Delta Coordinator
--- Git-like versioning system for brain state
local storage = require("codetyper.brain.storage")
local commit_mod = require("codetyper.brain.delta.commit")
local diff_mod = require("codetyper.brain.delta.diff")
local types = require("codetyper.brain.types")
local storage = require("codetyper.core.memory.storage")
local commit_mod = require("codetyper.core.memory.delta.commit")
local diff_mod = require("codetyper.core.memory.delta.diff")
local types = require("codetyper.core.memory.types")
local M = {}
@@ -17,7 +17,7 @@ M.diff = diff_mod
---@param trigger? string Trigger source
---@return string|nil Delta hash
function M.commit(message, trigger)
local graph = require("codetyper.brain.graph")
local graph = require("codetyper.core.memory.graph")
local changes = graph.get_pending_changes()
if #changes == 0 then
@@ -87,7 +87,7 @@ end
--- Apply changes to current state
---@param changes table[] Changes to apply
function M.apply_changes(changes)
local node_mod = require("codetyper.brain.graph.node")
local node_mod = require("codetyper.core.memory.graph.node")
for _, change in ipairs(changes) do
local parts = vim.split(change.path, ".", { plain = true })
@@ -117,7 +117,7 @@ function M.apply_changes(changes)
end
elseif parts[1] == "graph" then
-- Handle graph/edge changes
local edge_mod = require("codetyper.brain.graph.edge")
local edge_mod = require("codetyper.core.memory.graph.edge")
if parts[2] == "edges" and #parts >= 3 then
local edge_id = parts[3]
if change.op == types.DELTA_OPS.ADD then
@@ -168,17 +168,17 @@ end
--- Check if there are uncommitted changes
---@return boolean
function M.has_pending()
local graph = require("codetyper.brain.graph")
local node_pending = require("codetyper.brain.graph.node").pending
local edge_pending = require("codetyper.brain.graph.edge").pending
local graph = require("codetyper.core.memory.graph")
local node_pending = require("codetyper.core.memory.graph.node").pending
local edge_pending = require("codetyper.core.memory.graph.edge").pending
return #node_pending > 0 or #edge_pending > 0
end
--- Get status (like git status)
---@return table Status info
function M.status()
local node_pending = require("codetyper.brain.graph.node").pending
local edge_pending = require("codetyper.brain.graph.edge").pending
local node_pending = require("codetyper.core.memory.graph.node").pending
local edge_pending = require("codetyper.core.memory.graph.edge").pending
local adds = 0
local mods = 0
@@ -268,8 +268,8 @@ function M.reset()
})
-- Clear pending
require("codetyper.brain.graph.node").pending = {}
require("codetyper.brain.graph.edge").pending = {}
require("codetyper.core.memory.graph.node").pending = {}
require("codetyper.core.memory.graph.edge").pending = {}
storage.flush_all()
return true

View File

@@ -1,9 +1,9 @@
--- Brain Graph Edge Operations
--- CRUD operations for node connections
local storage = require("codetyper.brain.storage")
local hash = require("codetyper.brain.hash")
local types = require("codetyper.brain.types")
local storage = require("codetyper.core.memory.storage")
local hash = require("codetyper.core.memory.hash")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,11 +1,11 @@
--- Brain Graph Coordinator
--- High-level graph operations
local node = require("codetyper.brain.graph.node")
local edge = require("codetyper.brain.graph.edge")
local query = require("codetyper.brain.graph.query")
local storage = require("codetyper.brain.storage")
local types = require("codetyper.brain.types")
local node = require("codetyper.core.memory.graph.node")
local edge = require("codetyper.core.memory.graph.edge")
local query = require("codetyper.core.memory.graph.query")
local storage = require("codetyper.core.memory.storage")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,9 +1,9 @@
--- Brain Graph Node Operations
--- CRUD operations for learning nodes
local storage = require("codetyper.brain.storage")
local hash = require("codetyper.brain.hash")
local types = require("codetyper.brain.types")
local storage = require("codetyper.core.memory.storage")
local hash = require("codetyper.core.memory.hash")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,18 +1,18 @@
--- Brain Graph Query Engine
--- Multi-dimensional traversal and relevance scoring
local storage = require("codetyper.brain.storage")
local types = require("codetyper.brain.types")
local storage = require("codetyper.core.memory.storage")
local types = require("codetyper.core.memory.types")
local M = {}
--- Lazy load dependencies to avoid circular requires
local function get_node_module()
return require("codetyper.brain.graph.node")
return require("codetyper.core.memory.graph.node")
end
local function get_edge_module()
return require("codetyper.brain.graph.edge")
return require("codetyper.core.memory.graph.edge")
end
--- Compute text similarity (simple keyword matching)

View File

@@ -1,8 +1,8 @@
--- Brain Learning System
--- Graph-based knowledge storage with delta versioning
local storage = require("codetyper.brain.storage")
local types = require("codetyper.brain.types")
local storage = require("codetyper.core.memory.storage")
local types = require("codetyper.core.memory.types")
local M = {}
@@ -72,7 +72,7 @@ function M.learn(event)
return nil
end
local learners = require("codetyper.brain.learners")
local learners = require("codetyper.core.memory.learners")
local node_id = learners.process(event)
if node_id then
@@ -96,7 +96,7 @@ function M.query(opts)
return { nodes = {}, edges = {}, stats = {}, truncated = false }
end
local query_engine = require("codetyper.brain.graph.query")
local query_engine = require("codetyper.core.memory.graph.query")
return query_engine.execute(opts)
end
@@ -112,7 +112,7 @@ function M.get_context_for_llm(opts)
opts.max_tokens = opts.max_tokens or config.output.max_tokens
local result = M.query(opts)
local formatter = require("codetyper.brain.output.formatter")
local formatter = require("codetyper.core.memory.output.formatter")
if config.output.format == "json" then
return formatter.to_json(result, opts)
@@ -129,7 +129,7 @@ function M.commit(message)
return nil
end
local delta_mgr = require("codetyper.brain.delta")
local delta_mgr = require("codetyper.core.memory.delta")
return delta_mgr.commit(message)
end
@@ -141,7 +141,7 @@ function M.rollback(delta_hash)
return false
end
local delta_mgr = require("codetyper.brain.delta")
local delta_mgr = require("codetyper.core.memory.delta")
return delta_mgr.rollback(delta_hash)
end
@@ -153,7 +153,7 @@ function M.get_history(limit)
return {}
end
local delta_mgr = require("codetyper.brain.delta")
local delta_mgr = require("codetyper.core.memory.delta")
return delta_mgr.get_history(limit or 50)
end
@@ -170,7 +170,7 @@ function M.prune(opts)
unused_days = config.prune.unused_days,
}, opts or {})
local graph = require("codetyper.brain.graph")
local graph = require("codetyper.core.memory.graph")
return graph.prune(opts)
end

View File

@@ -1,7 +1,7 @@
--- Brain Convention Learner
--- Learns project conventions and coding standards
local types = require("codetyper.brain.types")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,7 +1,7 @@
--- Brain Correction Learner
--- Learns from user corrections and edits
local types = require("codetyper.brain.types")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,21 +1,21 @@
--- Brain Learners Coordinator
--- Routes learning events to appropriate learners
local types = require("codetyper.brain.types")
local types = require("codetyper.core.memory.types")
local M = {}
-- Lazy load learners
local function get_pattern_learner()
return require("codetyper.brain.learners.pattern")
return require("codetyper.core.memory.learners.pattern")
end
local function get_correction_learner()
return require("codetyper.brain.learners.correction")
return require("codetyper.core.memory.learners.correction")
end
local function get_convention_learner()
return require("codetyper.brain.learners.convention")
return require("codetyper.core.memory.learners.convention")
end
--- All available learners
@@ -99,7 +99,7 @@ function M.create_learning(learner, data, event)
local params = learner.create_node_params(data)
-- Get graph module
local graph = require("codetyper.brain.graph")
local graph = require("codetyper.core.memory.graph")
-- Find related nodes
local related_ids = {}
@@ -125,7 +125,7 @@ end
---@return string|nil Created node ID
function M.process_feedback(event)
local data = event.data or {}
local graph = require("codetyper.brain.graph")
local graph = require("codetyper.core.memory.graph")
local content = {
s = "Feedback: " .. (data.feedback or "unknown"),
@@ -167,7 +167,7 @@ end
---@return string|nil Created node ID
function M.process_session(event)
local data = event.data or {}
local graph = require("codetyper.brain.graph")
local graph = require("codetyper.core.memory.graph")
local content = {
s = event.type == "session_start" and "Session started" or "Session ended",

View File

@@ -1,7 +1,7 @@
--- Brain Pattern Learner
--- Detects and learns code patterns
local types = require("codetyper.brain.types")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,7 +1,7 @@
--- Brain Output Formatter
--- LLM-optimized output formatting
local types = require("codetyper.brain.types")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -1,7 +1,7 @@
--- Brain Output Coordinator
--- Manages LLM context generation
local formatter = require("codetyper.brain.output.formatter")
local formatter = require("codetyper.core.memory.output.formatter")
local M = {}
@@ -17,7 +17,7 @@ local DEFAULT_MAX_TOKENS = 4000
function M.generate(opts)
opts = opts or {}
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if not brain.is_initialized() then
return ""
end
@@ -138,7 +138,7 @@ end
--- Check if context is available
---@return boolean
function M.has_context()
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if not brain.is_initialized() then
return false
end
@@ -150,7 +150,7 @@ end
--- Get context stats
---@return table Stats
function M.stats()
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if not brain.is_initialized() then
return { available = false }
end

View File

@@ -1,8 +1,8 @@
--- Brain Storage Layer
--- Cache + disk persistence with lazy loading
local utils = require("codetyper.utils")
local types = require("codetyper.brain.types")
local utils = require("codetyper.support.utils")
local types = require("codetyper.core.memory.types")
local M = {}

View File

@@ -3,7 +3,8 @@
--- Executes tools requested by the LLM and returns results.
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
local logs = require("codetyper.adapters.nvim.ui.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

@@ -1,11 +1,13 @@
---@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 = {}
local prompts = require("codetyper.prompts.agents.loop")
---@class AgentMessage
---@field role "system"|"user"|"assistant"|"tool"
---@field content string|table
@@ -142,8 +144,8 @@ end
--- Execute the agent loop
---@param opts AgentLoopOpts
function M.run(opts)
local tools_mod = require("codetyper.agent.tools")
local llm = require("codetyper.llm")
local tools_mod = require("codetyper.core.tools")
local llm = require("codetyper.core.llm")
-- Get tools
local tools = opts.tools or tools_mod.list()
@@ -278,7 +280,7 @@ function M.run(opts)
local tool_opts = {
on_log = function(msg)
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({ type = "tool", message = msg })
end)
end,
@@ -345,24 +347,7 @@ end
function M.create(task, opts)
opts = opts or {}
local system_prompt = opts.system_prompt or [[You are a helpful coding assistant with access to tools.
Available tools:
- view: Read file contents
- grep: Search for patterns in files
- glob: Find files by pattern
- edit: Make targeted edits to files
- write: Create or overwrite files
- bash: Execute shell commands
When you need to perform a task:
1. Use tools to gather information
2. Plan your approach
3. Execute changes using appropriate tools
4. Verify the results
Always explain your reasoning before using tools.
When you're done, provide a clear summary of what was accomplished.]]
local system_prompt = opts.system_prompt or prompts.default_system_prompt
M.run(vim.tbl_extend("force", opts, {
system_prompt = system_prompt,
@@ -378,15 +363,13 @@ function M.dispatch(prompt, on_complete, opts)
opts = opts or {}
-- Sub-agents get limited tools by default
local tools_mod = require("codetyper.agent.tools")
local tools_mod = require("codetyper.core.tools")
local safe_tools = tools_mod.list(function(tool)
return tool.name == "view" or tool.name == "grep" or tool.name == "glob"
end)
M.run({
system_prompt = [[You are a research assistant. Your task is to find information and report back.
You have access to: view (read files), grep (search content), glob (find files).
Be thorough and report your findings clearly.]],
system_prompt = prompts.dispatch_prompt,
user_input = prompt,
tools = opts.tools or safe_tools,
max_iterations = opts.max_iterations or 5,

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.support.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

@@ -6,11 +6,12 @@
local M = {}
local queue = require("codetyper.agent.queue")
local patch = require("codetyper.agent.patch")
local worker = require("codetyper.agent.worker")
local confidence_mod = require("codetyper.agent.confidence")
local context_modal = require("codetyper.agent.context_modal")
local queue = require("codetyper.core.events.queue")
local patch = require("codetyper.core.diff.patch")
local worker = require("codetyper.core.scheduler.worker")
local confidence_mod = require("codetyper.core.llm.confidence")
local context_modal = require("codetyper.adapters.nvim.ui.context_modal")
local params = require("codetyper.params.agents.scheduler")
-- Setup context modal cleanup on exit
context_modal.setup()
@@ -21,15 +22,7 @@ local state = {
timer = nil,
poll_interval = 100, -- ms
paused = false,
config = {
enabled = true,
ollama_scout = true,
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
apply_delay_ms = 5000, -- Wait before applying code
remote_provider = "copilot", -- Default fallback provider
},
config = params.config,
}
--- Autocommand group for injection timing
@@ -124,7 +117,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,10 +131,14 @@ 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()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Retrying with additional context (original: %s)", original_event.id),
@@ -152,6 +149,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
@@ -159,14 +229,94 @@ local function handle_worker_result(event, result)
-- Check if LLM needs more context
if result.needs_context then
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Event %s: LLM needs more context, opening modal", event.id),
})
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.adapters.nvim.ui.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)
@@ -178,7 +328,7 @@ local function handle_worker_result(event, result)
-- Failed - try escalation if this was ollama
if result.worker_type == "ollama" and event.attempt_count < 2 then
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format(
@@ -208,7 +358,7 @@ local function handle_worker_result(event, result)
if needs_escalation and result.worker_type == "ollama" and event.attempt_count < 2 then
-- Low confidence from ollama - escalate to remote
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format(
@@ -233,7 +383,7 @@ local function handle_worker_result(event, result)
-- Schedule patch application after delay (gives user time to review/cancel)
local delay = state.config.apply_delay_ms or 5000
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Code ready. Applying in %.1f seconds...", delay / 1000),
@@ -266,7 +416,7 @@ local function dispatch_next()
local should_skip, skip_reason = queue.check_precedence(event)
if should_skip then
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "warning",
message = string.format("Event %s skipped: %s", event.id, skip_reason or "conflict"),
@@ -282,7 +432,7 @@ local function dispatch_next()
-- Log dispatch with intent/scope info
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
local intent_info = event.intent and event.intent.type or "unknown"
local scope_info = event.scope and event.scope.type ~= "file"
and string.format("%s:%s", event.scope.type, event.scope.name or "anon")
@@ -321,10 +471,10 @@ 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")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Patches flushed: %d applied, %d stale", applied, stale),
@@ -337,7 +487,7 @@ function M.schedule_patch_flush()
if not waiting_to_flush then
waiting_to_flush = true
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Waiting for user to finish typing before applying code...",
@@ -382,7 +532,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 +544,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",
@@ -478,7 +628,7 @@ function M.start(config)
scheduler_loop()
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Scheduler started",
@@ -510,7 +660,7 @@ function M.stop()
end
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = "Scheduler stopped",
@@ -563,7 +713,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 +724,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

@@ -6,7 +6,8 @@
local M = {}
local confidence = require("codetyper.agent.confidence")
local params = require("codetyper.params.agents.worker")
local confidence = require("codetyper.core.llm.confidence")
---@class WorkerResult
---@field success boolean Whether the request succeeded
@@ -32,20 +33,7 @@ local confidence = require("codetyper.agent.confidence")
local worker_counter = 0
--- Patterns that indicate LLM needs more context (must be near start of response)
local context_needed_patterns = {
"^%s*i need more context",
"^%s*i'm sorry.-i need more",
"^%s*i apologize.-i need more",
"^%s*could you provide more context",
"^%s*could you please provide more",
"^%s*can you clarify",
"^%s*please provide more context",
"^%s*more information needed",
"^%s*not enough context",
"^%s*i don't have enough",
"^%s*unclear what you",
"^%s*what do you mean by",
}
local context_needed_patterns = params.context_needed_patterns
--- Check if response indicates need for more context
--- Only triggers if the response primarily asks for context (no substantial code)
@@ -83,6 +71,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 +108,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```")
@@ -176,12 +184,7 @@ end
local active_workers = {}
--- Default timeouts by provider type
local default_timeouts = {
ollama = 30000, -- 30s for local
openai = 60000, -- 60s for remote
gemini = 60000,
copilot = 60000,
}
local default_timeouts = params.default_timeouts
--- Generate worker ID
---@return string
@@ -352,20 +355,61 @@ 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
---@return table context
local function build_prompt(event)
local intent_mod = require("codetyper.agent.intent")
local intent_mod = require("codetyper.core.intent")
-- 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
@@ -376,7 +420,7 @@ local function build_prompt(event)
local indexed_context = nil
local indexed_content = ""
pcall(function()
local indexer = require("codetyper.indexer")
local indexer = require("codetyper.features.indexer")
indexed_context = indexer.get_context_for({
file = event.target_path,
intent = event.intent,
@@ -395,7 +439,7 @@ local function build_prompt(event)
-- Get brain memories - contextual recall based on current task
local brain_context = ""
pcall(function()
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
if brain.is_initialized() then
-- Query brain for relevant memories based on:
-- 1. Current file (file-specific patterns)
@@ -458,6 +502,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 +621,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 +666,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
@@ -578,7 +726,7 @@ function M.create(event, worker_type, callback)
-- Log worker creation
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "worker",
message = string.format("Worker %s started (%s)", worker.id, worker_type),
@@ -608,7 +756,7 @@ function M.start(worker)
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "warning",
message = string.format("Worker %s timed out after %dms", worker.id, worker.timeout_ms),
@@ -662,7 +810,7 @@ function M.start(worker)
-- Log if pondering occurred
if usage_or_metadata.pondered then
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format(
@@ -680,7 +828,7 @@ function M.start(worker)
-- Use smart selection or direct client
if use_smart_selection then
local llm = require("codetyper.llm")
local llm = require("codetyper.core.llm")
llm.smart_generate(prompt, context, handle_response)
else
-- Get client and execute directly
@@ -706,7 +854,7 @@ function M.complete(worker, response, error, usage)
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "error",
message = string.format("Worker %s failed: %s", worker.id, error),
@@ -732,7 +880,7 @@ function M.complete(worker, response, error, usage)
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Worker %s: LLM needs more context", worker.id),
@@ -756,7 +904,7 @@ function M.complete(worker, response, error, usage)
-- Log the full raw LLM response (for debugging)
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "response",
message = "--- LLM Response ---",
@@ -777,7 +925,7 @@ function M.complete(worker, response, error, usage)
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "success",
message = string.format(
@@ -824,7 +972,7 @@ function M.cancel(worker_id)
active_workers[worker_id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.add({
type = "info",
message = string.format("Worker %s cancelled", worker_id),

View File

@@ -14,62 +14,10 @@ local M = {}
---@field name string|nil Name of the function/class if available
--- Node types that represent function-like scopes per language
local function_nodes = {
-- Lua
["function_declaration"] = "function",
["function_definition"] = "function",
["local_function"] = "function",
["function"] = "function",
-- JavaScript/TypeScript
["function_declaration"] = "function",
["function_expression"] = "function",
["arrow_function"] = "function",
["method_definition"] = "method",
["function"] = "function",
-- Python
["function_definition"] = "function",
["async_function_definition"] = "function",
-- Go
["function_declaration"] = "function",
["method_declaration"] = "method",
-- Rust
["function_item"] = "function",
["impl_item"] = "method",
-- Ruby
["method"] = "method",
["singleton_method"] = "method",
-- Java/C#
["method_declaration"] = "method",
["constructor_declaration"] = "method",
-- C/C++
["function_definition"] = "function",
}
--- Node types that represent class-like scopes
local class_nodes = {
["class_declaration"] = "class",
["class_definition"] = "class",
["class"] = "class",
["struct_item"] = "class",
["impl_item"] = "class",
["interface_declaration"] = "class",
["module"] = "class",
}
--- Node types that represent block scopes
local block_nodes = {
["block"] = "block",
["statement_block"] = "block",
["compound_statement"] = "block",
["do_block"] = "block",
}
local params = require("codetyper.params.agents.scope")
local function_nodes = params.function_nodes
local class_nodes = params.class_nodes
local block_nodes = params.block_nodes
--- Check if Tree-sitter is available for buffer
---@param bufnr number
@@ -282,13 +230,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 +258,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

@@ -3,81 +3,22 @@
--- Tool for executing shell commands with safety checks.
---@brief ]]
local Base = require("codetyper.agent.tools.base")
local Base = require("codetyper.core.tools.base")
local description = require("codetyper.prompts.agents.bash").description
local params = require("codetyper.params.agents.bash").params
local returns = require("codetyper.params.agents.bash").returns
local BANNED_COMMANDS = require("codetyper.commands.agents.banned").BANNED_COMMANDS
local BANNED_PATTERNS = require("codetyper.commands.agents.banned").BANNED_PATTERNS
---@class CoderTool
local M = setmetatable({}, Base)
M.name = "bash"
M.description = [[Executes a bash command in a shell.
IMPORTANT RULES:
- Do NOT use bash to read files (use 'view' tool instead)
- Do NOT use bash to modify files (use 'write' or 'edit' tools instead)
- Do NOT use interactive commands (vim, nano, less, etc.)
- Commands timeout after 2 minutes by default
Allowed uses:
- Running builds (make, npm run build, cargo build)
- Running tests (npm test, pytest, cargo test)
- Git operations (git status, git diff, git commit)
- Package management (npm install, pip install)
- System info commands (ls, pwd, which)]]
M.params = {
{
name = "command",
description = "The shell command to execute",
type = "string",
},
{
name = "cwd",
description = "Working directory for the command (optional)",
type = "string",
optional = true,
},
{
name = "timeout",
description = "Timeout in milliseconds (default: 120000)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "stdout",
description = "Command output",
type = "string",
},
{
name = "error",
description = "Error message if command failed",
type = "string",
optional = true,
},
}
M.description = description
M.params = params
M.returns = returns
M.requires_confirmation = true
--- Banned commands for safety
local BANNED_COMMANDS = {
"rm -rf /",
"rm -rf /*",
"dd if=/dev/zero",
"mkfs",
":(){ :|:& };:",
"> /dev/sda",
}
--- Banned patterns
local BANNED_PATTERNS = {
"curl.*|.*sh",
"wget.*|.*sh",
"rm%s+%-rf%s+/",
}
--- Check if command is safe
---@param command string
---@return boolean safe

View File

@@ -2,61 +2,21 @@
---@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")
local Base = require("codetyper.core.tools.base")
local description = require("codetyper.prompts.agents.edit").description
local params = require("codetyper.params.agents.edit").params
local returns = require("codetyper.params.agents.edit").returns
---@class CoderTool
local M = setmetatable({}, Base)
M.name = "edit"
M.description = [[Makes a targeted edit to a file by replacing text.
The old_string should match the content you want to replace. The tool uses multiple
matching strategies with fallbacks:
1. Exact match
2. Whitespace-normalized match
3. Indentation-flexible match
4. Line-trimmed match
5. Fuzzy anchor-based match
For creating new files, use old_string="" and provide the full content in new_string.
For large changes, consider using 'write' tool instead.]]
M.params = {
{
name = "path",
description = "Path to the file to edit",
type = "string",
},
{
name = "old_string",
description = "Text to find and replace (empty string to create new file or append)",
type = "string",
},
{
name = "new_string",
description = "Text to replace with",
type = "string",
},
}
M.returns = {
{
name = "success",
description = "Whether the edit was applied",
type = "boolean",
},
{
name = "error",
description = "Error message if edit failed",
type = "string",
optional = true,
},
}
M.description = description
M.params = params
M.returns = returns
M.requires_confirmation = false
--- Normalize line endings to LF
@@ -211,11 +171,7 @@ local function levenshtein(s1, s2)
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
)
matrix[i][j] = math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
end
end
@@ -245,10 +201,13 @@ local function fuzzy_anchor_match(content, old_str, threshold)
local candidates = {}
for i, line in ipairs(content_lines) do
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed == first_line or (
#first_line > 0 and
1 - (levenshtein(trimmed, first_line) / math.max(#trimmed, #first_line)) >= threshold
) then
if
trimmed == first_line
or (
#first_line > 0
and 1 - (levenshtein(trimmed, first_line) / math.max(#trimmed, #first_line)) >= threshold
)
then
table.insert(candidates, i)
end
end
@@ -258,10 +217,13 @@ local function fuzzy_anchor_match(content, old_str, threshold)
local expected_end = start_idx + #old_lines - 1
if expected_end <= #content_lines then
local end_line = content_lines[expected_end]:match("^%s*(.-)%s*$")
if end_line == last_line or (
#last_line > 0 and
1 - (levenshtein(end_line, last_line) / math.max(#end_line, #last_line)) >= threshold
) then
if
end_line == last_line
or (
#last_line > 0
and 1 - (levenshtein(end_line, last_line) / math.max(#end_line, #last_line)) >= threshold
)
then
-- Calculate positions
local before = table.concat(vim.list_slice(content_lines, 1, start_idx - 1), "\n")
local block = table.concat(vim.list_slice(content_lines, start_idx, expected_end), "\n")

View File

@@ -3,7 +3,7 @@
--- Tool for finding files by glob pattern.
---@brief ]]
local Base = require("codetyper.agent.tools.base")
local Base = require("codetyper.core.tools.base")
---@class CoderTool
local M = setmetatable({}, Base)

View File

@@ -3,61 +3,18 @@
--- Tool for searching file contents using ripgrep.
---@brief ]]
local Base = require("codetyper.agent.tools.base")
local Base = require("codetyper.core.tools.base")
local description = require("codetyper.params.agents.grep").description
local params = require("codetyper.prompts.agents.grep").params
local returns = require("codetyper.prompts.agents.grep").returns
---@class CoderTool
local M = setmetatable({}, Base)
M.name = "grep"
M.description = [[Searches for a pattern in files using ripgrep.
Returns file paths and matching lines. Use this to find code by content.
Example patterns:
- "function foo" - Find function definitions
- "import.*react" - Find React imports
- "TODO|FIXME" - Find todo comments]]
M.params = {
{
name = "pattern",
description = "Regular expression pattern to search for",
type = "string",
},
{
name = "path",
description = "Directory or file to search in (default: project root)",
type = "string",
optional = true,
},
{
name = "include",
description = "File glob pattern to include (e.g., '*.lua')",
type = "string",
optional = true,
},
{
name = "max_results",
description = "Maximum number of results (default: 50)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "matches",
description = "JSON array of matches with file, line_number, and content",
type = "string",
},
{
name = "error",
description = "Error message if search failed",
type = "string",
optional = true,
},
}
M.description = description
M.params = params
M.returns = returns
M.requires_confirmation = false

View File

@@ -0,0 +1,90 @@
---@mod codetyper.agent.tools Tool definitions for the agent system
---
--- Defines available tools that the LLM can use to interact with files and system.
local M = {}
--- Tool definitions in a provider-agnostic format
M.definitions = require("codetyper.params.agents.tools").definitions
--- Convert tool definitions to Claude API format
---@return table[] Tools in Claude's expected format
function M.to_claude_format()
local tools = {}
for _, tool in pairs(M.definitions) do
table.insert(tools, {
name = tool.name,
description = tool.description,
input_schema = tool.parameters,
})
end
return tools
end
--- Convert tool definitions to OpenAI API format
---@return table[] Tools in OpenAI's expected format
function M.to_openai_format()
local tools = {}
for _, tool in pairs(M.definitions) do
table.insert(tools, {
type = "function",
["function"] = {
name = tool.name,
description = tool.description,
parameters = tool.parameters,
},
})
end
return tools
end
--- Convert tool definitions to prompt format for Ollama
---@return string Formatted tool descriptions for system prompt
function M.to_prompt_format()
local prompts = require("codetyper.prompts.agents.tools").instructions
local lines = {
prompts.intro,
"",
}
for _, tool in pairs(M.definitions) do
table.insert(lines, "## " .. tool.name)
table.insert(lines, tool.description)
table.insert(lines, "")
table.insert(lines, "Parameters:")
for prop_name, prop in pairs(tool.parameters.properties) do
local required = vim.tbl_contains(tool.parameters.required or {}, prop_name)
local req_str = required and " (required)" or " (optional)"
table.insert(lines, " - " .. prop_name .. ": " .. prop.description .. req_str)
end
table.insert(lines, "")
end
table.insert(lines, "---")
table.insert(lines, "")
table.insert(lines, prompts.header)
table.insert(lines, prompts.example)
table.insert(lines, "")
table.insert(lines, prompts.footer)
return table.concat(lines, "\n")
end
--- Get a list of tool names
---@return string[]
function M.get_tool_names()
local names = {}
for name, _ in pairs(M.definitions) do
table.insert(names, name)
end
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

@@ -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 = {}
@@ -162,27 +162,27 @@ end
--- Load built-in tools
function M.load_builtins()
-- View file tool
local view = require("codetyper.agent.tools.view")
local view = require("codetyper.core.tools.view")
M.register(view)
-- Bash tool
local bash = require("codetyper.agent.tools.bash")
local bash = require("codetyper.core.tools.bash")
M.register(bash)
-- Grep tool
local grep = require("codetyper.agent.tools.grep")
local grep = require("codetyper.core.tools.grep")
M.register(grep)
-- Glob tool
local glob = require("codetyper.agent.tools.glob")
local glob = require("codetyper.core.tools.glob")
M.register(glob)
-- Write file tool
local write = require("codetyper.agent.tools.write")
local write = require("codetyper.core.tools.write")
M.register(write)
-- Edit tool
local edit = require("codetyper.agent.tools.edit")
local edit = require("codetyper.core.tools.edit")
M.register(edit)
end

View File

@@ -3,54 +3,19 @@
--- Tool for reading file contents with line range support.
---@brief ]]
local Base = require("codetyper.agent.tools.base")
local Base = require("codetyper.core.tools.base")
---@class CoderTool
local M = setmetatable({}, Base)
M.name = "view"
M.description = [[Reads the content of a file.
local params = require("codetyper.params.agents.view")
local description = require("codetyper.prompts.agents.view").description
Usage notes:
- Provide the file path relative to the project root
- Use start_line and end_line to read specific sections
- If content is truncated, use line ranges to read in chunks
- Returns JSON with content, total_line_count, and is_truncated]]
M.params = {
{
name = "path",
description = "Path to the file (relative to project root or absolute)",
type = "string",
},
{
name = "start_line",
description = "Line number to start reading (1-indexed)",
type = "integer",
optional = true,
},
{
name = "end_line",
description = "Line number to end reading (1-indexed, inclusive)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "content",
description = "File contents as JSON with content, total_line_count, is_truncated",
type = "string",
},
{
name = "error",
description = "Error message if file could not be read",
type = "string",
optional = true,
},
}
M.description = description
M.params = params.params
M.returns = params.returns
M.requires_confirmation = false

View File

@@ -3,46 +3,17 @@
--- Tool for creating or overwriting files.
---@brief ]]
local Base = require("codetyper.agent.tools.base")
local Base = require("codetyper.core.tools.base")
local description = require("codetyper.prompts.agents.write").description
local params = require("codetyper.params.agents.write")
---@class CoderTool
local M = setmetatable({}, Base)
M.name = "write"
M.description = [[Creates or overwrites a file with new content.
IMPORTANT:
- This will completely replace the file contents
- Use 'edit' tool for partial modifications
- Parent directories will be created if needed]]
M.params = {
{
name = "path",
description = "Path to the file to write",
type = "string",
},
{
name = "content",
description = "Content to write to the file",
type = "string",
},
}
M.returns = {
{
name = "success",
description = "Whether the file was written successfully",
type = "boolean",
},
{
name = "error",
description = "Error message if write failed",
type = "string",
optional = true,
},
}
M.description = description
M.params = params.params
M.returns = params.returns
M.requires_confirmation = true

View File

@@ -0,0 +1,268 @@
---@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.support.utils")
local params = require("codetyper.params.agents.context")
--- 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 = params.ignore_patterns
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 paparams.important_filesnd
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.isdirectoparams.indicators 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

@@ -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 = {}
@@ -31,10 +31,7 @@ local M = {}
---@field on_complete? fun(result: string|nil, error: string|nil) Called when done
---@field on_status? fun(status: string) Status updates
--- Generate unique tool call ID
local function generate_tool_call_id()
return "call_" .. string.format("%x", os.time()) .. "_" .. string.format("%x", math.random(0, 0xFFFF))
end
local utils = require("codetyper.support.utils")
--- Load agent definition
---@param name string Agent name
@@ -72,59 +69,7 @@ local function load_agent(name)
end
-- Built-in agents
local builtin_agents = {
coder = {
name = "coder",
description = "Full-featured coding agent with file modification capabilities",
system_prompt = [[You are an expert software engineer. You have access to tools to read, write, and modify files.
## Your Capabilities
- Read files to understand the codebase
- Search for patterns with grep and glob
- Create new files with write tool
- Edit existing files with precise replacements
- Execute shell commands for builds and tests
## Guidelines
1. Always read relevant files before making changes
2. Make minimal, focused changes
3. Follow existing code style and patterns
4. Create tests when adding new functionality
5. Verify changes work by running tests or builds
## Important Rules
- NEVER guess file contents - always read first
- Make precise edits using exact string matching
- Explain your reasoning before making changes
- If unsure, ask for clarification]],
tools = { "view", "edit", "write", "grep", "glob", "bash" },
},
planner = {
name = "planner",
description = "Planning agent - read-only, helps design implementations",
system_prompt = [[You are a software architect. Analyze codebases and create implementation plans.
You can read files and search the codebase, but cannot modify files.
Your role is to:
1. Understand the existing architecture
2. Identify relevant files and patterns
3. Create step-by-step implementation plans
4. Suggest which files to modify and how
Be thorough in your analysis before making recommendations.]],
tools = { "view", "grep", "glob" },
},
explorer = {
name = "explorer",
description = "Exploration agent - quickly find information in codebase",
system_prompt = [[You are a codebase exploration assistant. Find information quickly and report back.
Your goal is to efficiently search and summarize findings.
Use glob to find files, grep to search content, and view to read specific files.
Be concise and focused in your responses.]],
tools = { "view", "grep", "glob" },
},
}
local builtin_agents = require("codetyper.prompts.agents.personas").builtin
return builtin_agents[name]
end
@@ -232,7 +177,7 @@ end
---@param provider string "openai"|"claude"
---@return table[] Formatted tools
local function build_tools(tool_names, provider)
local tools_mod = require("codetyper.agent.tools")
local tools_mod = require("codetyper.core.tools")
local tools = {}
for _, name in ipairs(tool_names) do
@@ -289,7 +234,7 @@ end
---@return string result
---@return string|nil error
local function execute_tool(tool_call, opts)
local tools_mod = require("codetyper.agent.tools")
local tools_mod = require("codetyper.core.tools")
local name = tool_call["function"].name
local args = tool_call["function"].arguments
@@ -365,7 +310,7 @@ local function parse_tool_calls(response, provider)
end
table.insert(tool_calls, {
id = block.id or generate_tool_call_id(),
id = block.id or utils.generate_id("call"),
type = "function",
["function"] = {
name = block.name,
@@ -418,7 +363,7 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
-- Use native tool calling APIs
if provider == "copilot" then
local client = require("codetyper.llm.copilot")
local client = require("codetyper.core.llm.copilot")
-- Copilot's generate_with_tools expects messages in a specific format
-- Convert to the format it expects
@@ -449,7 +394,7 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
elseif block.type == "tool_use" then
table.insert(result.content, {
type = "tool_use",
id = block.id or generate_tool_call_id(),
id = block.id or utils.generate_id("call"),
name = block.name,
input = block.input,
})
@@ -461,7 +406,7 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
callback(result, nil)
end)
elseif provider == "openai" then
local client = require("codetyper.llm.openai")
local client = require("codetyper.core.llm.openai")
-- OpenAI's generate_with_tools
local converted_messages = {}
@@ -490,7 +435,7 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
elseif block.type == "tool_use" then
table.insert(result.content, {
type = "tool_use",
id = block.id or generate_tool_call_id(),
id = block.id or utils.generate_id("call"),
name = block.name,
input = block.input,
})
@@ -502,7 +447,7 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
callback(result, nil)
end)
elseif provider == "ollama" then
local client = require("codetyper.llm.ollama")
local client = require("codetyper.core.llm.ollama")
-- Ollama's generate_with_tools (text-based tool calling)
local converted_messages = {}
@@ -523,24 +468,23 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
end)
else
-- Fallback for other providers (ollama, etc.) - use text-based parsing
local client = require("codetyper.llm." .. provider)
local client = require("codetyper.core.llm." .. provider)
-- Build prompt from messages
local prompts = require("codetyper.prompts.agents")
local prompt_parts = {}
for _, msg in ipairs(messages) do
if msg.role == "user" then
local content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content)
table.insert(prompt_parts, "User: " .. content)
table.insert(prompt_parts, prompts.text_user_prefix .. content)
elseif msg.role == "assistant" then
local content = type(msg.content) == "string" and msg.content or vim.json.encode(msg.content)
table.insert(prompt_parts, "Assistant: " .. content)
table.insert(prompt_parts, prompts.text_assistant_prefix .. content)
end
end
-- Add tool descriptions to prompt for text-based providers
local tool_desc = "\n\n## Available Tools\n"
tool_desc = tool_desc .. "Call tools by outputting JSON in this format:\n"
tool_desc = tool_desc .. '```json\n{"tool": "tool_name", "arguments": {...}}\n```\n\n'
local tool_desc = require("codetyper.prompts.agents").tool_instructions_text
for _, tool in ipairs(tools) do
local name = tool.name or (tool["function"] and tool["function"].name)
local desc = tool.description or (tool["function"] and tool["function"].description)
@@ -573,7 +517,7 @@ local function call_llm(messages, tools, system_prompt, provider, model, callbac
if ok and parsed.tool then
table.insert(result.content, {
type = "tool_use",
id = generate_tool_call_id(),
id = utils.generate_id("call"),
name = parsed.tool,
input = parsed.arguments or {},
})
@@ -617,11 +561,7 @@ function M.run(opts)
-- Add initial file context if provided
if opts.files and #opts.files > 0 then
local file_context = "# Initial Files\n"
for _, file_path in ipairs(opts.files) do
local content = table.concat(vim.fn.readfile(file_path) or {}, "\n")
file_context = file_context .. string.format("\n## %s\n```\n%s\n```\n", file_path, content)
end
local file_context = require("codetyper.prompts.agents").format_file_context(opts.files)
table.insert(history, { role = "user", content = file_context })
table.insert(history, { role = "assistant", content = "I've reviewed the provided files. What would you like me to do?" })
end
@@ -638,7 +578,7 @@ function M.run(opts)
local tool_names = agent.tools or { "view", "edit", "write", "grep", "glob", "bash" }
-- Ensure tools are loaded
local tools_mod = require("codetyper.agent.tools")
local tools_mod = require("codetyper.core.tools")
tools_mod.setup()
-- Build tools for API
@@ -736,27 +676,7 @@ function M.init_agents_dir()
vim.fn.mkdir(agents_dir, "p")
-- Create example agent
local example_agent = [[---
description: Example custom agent
tools: view,grep,glob,edit,write
model:
---
# Custom Agent
You are a custom coding agent. Describe your specialized behavior here.
## Your Role
- Define what this agent specializes in
- List specific capabilities
## Guidelines
- Add agent-specific rules
- Define coding standards to follow
## Examples
Provide examples of how to handle common tasks.
]]
local example_agent = require("codetyper.prompts.agents.templates").agent
local example_path = agents_dir .. "/example.md"
if vim.fn.filereadable(example_path) ~= 1 then
@@ -772,30 +692,7 @@ function M.init_rules_dir()
vim.fn.mkdir(rules_dir, "p")
-- Create example rule
local example_rule = [[# Code Style
Follow these coding standards:
## General
- Use consistent indentation (tabs or spaces based on project)
- Keep lines under 100 characters
- Add comments for complex logic
## Naming Conventions
- Use descriptive variable names
- Functions should be verbs (e.g., getUserData, calculateTotal)
- Constants in UPPER_SNAKE_CASE
## Testing
- Write tests for new functionality
- Aim for >80% code coverage
- Test edge cases
## Documentation
- Document public APIs
- Include usage examples
- Keep docs up to date with code
]]
local example_rule = require("codetyper.prompts.agents.templates").rule
local example_path = rules_dir .. "/code-style.md"
if vim.fn.filereadable(example_path) ~= 1 then
@@ -817,7 +714,10 @@ function M.list_agents()
local agents = {}
-- Built-in agents
local builtins = { "coder", "planner", "explorer" }
local personas = require("codetyper.prompts.agents.personas").builtin
local builtins = vim.tbl_keys(personas)
table.sort(builtins)
for _, name in ipairs(builtins) do
local agent = load_agent(name)
if agent then

View File

@@ -4,12 +4,14 @@
local M = {}
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 utils = require("codetyper.utils")
local logs = require("codetyper.agent.logs")
local tools = require("codetyper.core.tools")
local executor = require("codetyper.core.scheduler.executor")
local parser = require("codetyper.core.llm.parser")
local diff = require("codetyper.core.diff.diff")
local diff_review = require("codetyper.adapters.nvim.ui.diff_review")
local resume = require("codetyper.core.scheduler.resume")
local utils = require("codetyper.support.utils")
local logs = require("codetyper.adapters.nvim.ui.logs")
---@class AgentState
---@field conversation table[] Message history for multi-turn
@@ -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,13 +101,13 @@ 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
local llm = require("codetyper.llm")
local llm = require("codetyper.core.llm")
local client = llm.get_client()
-- Check if client supports tools
@@ -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.core.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

@@ -0,0 +1,425 @@
---@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 = {}
local config_params = require("codetyper.params.agents.linter")
local prompts = require("codetyper.prompts.agents.linter")
--- Configuration
local config = config_params.config
--- 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.adapters.nvim.ui.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.adapters.nvim.ui.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(
prompts.fix_request,
table.concat(error_list, "\n"),
start_line,
end_line,
code_context
)
-- Queue the fix through the scheduler
local scheduler = require("codetyper.core.scheduler.scheduler")
local queue = require("codetyper.core.events.queue")
local patch_mod = require("codetyper.core.diff.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.adapters.nvim.ui.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

@@ -10,6 +10,8 @@ local M = {}
---@field allow_list table<string, boolean> Patterns always allowed
---@field deny_list table<string, boolean> Patterns always denied
local params = require("codetyper.params.agents.permissions")
local state = {
session_allowed = {},
allow_list = {},
@@ -17,59 +19,10 @@ local state = {
}
--- Dangerous command patterns that should never be auto-allowed
local DANGEROUS_PATTERNS = {
"^rm%s+%-rf",
"^rm%s+%-r%s+/",
"^rm%s+/",
"^sudo%s+rm",
"^chmod%s+777",
"^chmod%s+%-R",
"^chown%s+%-R",
"^dd%s+",
"^mkfs",
"^fdisk",
"^format",
":.*>%s*/dev/",
"^curl.*|.*sh",
"^wget.*|.*sh",
"^eval%s+",
"`;.*`",
"%$%(.*%)",
"fork%s*bomb",
}
local DANGEROUS_PATTERNS = params.dangerous_patterns
--- Safe command patterns that can be auto-allowed
local SAFE_PATTERNS = {
"^ls%s",
"^ls$",
"^cat%s",
"^head%s",
"^tail%s",
"^grep%s",
"^find%s",
"^pwd$",
"^echo%s",
"^wc%s",
"^which%s",
"^type%s",
"^file%s",
"^stat%s",
"^git%s+status",
"^git%s+log",
"^git%s+diff",
"^git%s+branch",
"^git%s+show",
"^npm%s+list",
"^npm%s+ls",
"^npm%s+outdated",
"^yarn%s+list",
"^cargo%s+check",
"^cargo%s+test",
"^go%s+test",
"^go%s+build",
"^make%s+test",
"^make%s+check",
}
local SAFE_PATTERNS = params.safe_patterns
---@alias PermissionLevel "allow"|"allow_session"|"allow_list"|"reject"

View File

@@ -1,8 +1,8 @@
---@mod codetyper.ask Ask window for Codetyper.nvim (similar to avante.nvim)
---@mod codetyper.ask Ask window for Codetyper.nvim
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
---@class AskState
---@field input_buf number|nil Input buffer
@@ -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
@@ -344,7 +345,7 @@ local function setup_log_listener()
-- Remove existing listener if any
if state.log_listener_id then
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.remove_listener(state.log_listener_id)
end)
state.log_listener_id = nil
@@ -361,7 +362,7 @@ end
local function remove_log_listener()
if state.log_listener_id then
pcall(function()
local logs = require("codetyper.agent.logs")
local logs = require("codetyper.adapters.nvim.ui.logs")
logs.remove_listener(state.log_listener_id)
end)
state.log_listener_id = nil
@@ -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

@@ -6,7 +6,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
---@class ExplorationState
---@field is_exploring boolean

View File

@@ -17,74 +17,9 @@ local M = {}
---@field body string[] Non-import code lines
---@field import_lines table<number, boolean> Map of line numbers that are imports
--- Language-specific import patterns
local import_patterns = {
-- JavaScript/TypeScript
javascript = {
{ pattern = "^%s*import%s+.+%s+from%s+['\"]", multi_line = true },
{ pattern = "^%s*import%s+['\"]", multi_line = false },
{ pattern = "^%s*import%s*{", multi_line = true },
{ pattern = "^%s*import%s*%*", multi_line = true },
{ pattern = "^%s*export%s+{.+}%s+from%s+['\"]", multi_line = true },
{ pattern = "^%s*const%s+%w+%s*=%s*require%(['\"]", multi_line = false },
{ pattern = "^%s*let%s+%w+%s*=%s*require%(['\"]", multi_line = false },
{ pattern = "^%s*var%s+%w+%s*=%s*require%(['\"]", multi_line = false },
},
-- Python
python = {
{ pattern = "^%s*import%s+%w", multi_line = false },
{ pattern = "^%s*from%s+[%w%.]+%s+import%s+", multi_line = true },
},
-- Lua
lua = {
{ pattern = "^%s*local%s+%w+%s*=%s*require%s*%(?['\"]", multi_line = false },
{ pattern = "^%s*require%s*%(?['\"]", multi_line = false },
},
-- Go
go = {
{ pattern = "^%s*import%s+%(?", multi_line = true },
},
-- Rust
rust = {
{ pattern = "^%s*use%s+", multi_line = true },
{ pattern = "^%s*extern%s+crate%s+", multi_line = false },
},
-- C/C++
c = {
{ pattern = "^%s*#include%s*[<\"]", multi_line = false },
},
-- Java/Kotlin
java = {
{ pattern = "^%s*import%s+", multi_line = false },
},
-- Ruby
ruby = {
{ pattern = "^%s*require%s+['\"]", multi_line = false },
{ pattern = "^%s*require_relative%s+['\"]", multi_line = false },
},
-- PHP
php = {
{ pattern = "^%s*use%s+", multi_line = false },
{ pattern = "^%s*require%s+['\"]", multi_line = false },
{ pattern = "^%s*require_once%s+['\"]", multi_line = false },
{ pattern = "^%s*include%s+['\"]", multi_line = false },
{ pattern = "^%s*include_once%s+['\"]", multi_line = false },
},
}
-- Alias common extensions to language configs
import_patterns.ts = import_patterns.javascript
import_patterns.tsx = import_patterns.javascript
import_patterns.jsx = import_patterns.javascript
import_patterns.mjs = import_patterns.javascript
import_patterns.cjs = import_patterns.javascript
import_patterns.py = import_patterns.python
import_patterns.cpp = import_patterns.c
import_patterns.hpp = import_patterns.c
import_patterns.h = import_patterns.c
import_patterns.kt = import_patterns.java
import_patterns.rs = import_patterns.rust
import_patterns.rb = import_patterns.ruby
local utils = require("codetyper.support.utils")
local languages = require("codetyper.params.agents.languages")
local import_patterns = languages.import_patterns
--- Check if a line is an import statement for the given language
---@param line string
@@ -100,83 +35,13 @@ local function is_import_line(line, patterns)
return false, false
end
--- Check if a line is empty or a comment
---@param line string
---@param filetype string
---@return boolean
local function is_empty_or_comment(line, filetype)
local trimmed = line:match("^%s*(.-)%s*$")
if trimmed == "" then
return true
end
-- Language-specific comment patterns
local comment_patterns = {
lua = { "^%-%-" },
python = { "^#" },
javascript = { "^//", "^/%*", "^%*" },
typescript = { "^//", "^/%*", "^%*" },
go = { "^//", "^/%*", "^%*" },
rust = { "^//", "^/%*", "^%*" },
c = { "^//", "^/%*", "^%*", "^#" },
java = { "^//", "^/%*", "^%*" },
ruby = { "^#" },
php = { "^//", "^/%*", "^%*", "^#" },
}
local patterns = comment_patterns[filetype] or comment_patterns.javascript
for _, pattern in ipairs(patterns) do
if trimmed:match(pattern) then
return true
end
end
return false
end
--- Check if a line ends a multi-line import
---@param line string
---@param filetype string
---@return boolean
local function ends_multiline_import(line, filetype)
-- Check for closing patterns
if filetype == "javascript" or filetype == "typescript" or filetype == "ts" or filetype == "tsx" then
-- ES6 imports end with 'from "..." ;' or just ';' or a line with just '}'
if line:match("from%s+['\"][^'\"]+['\"]%s*;?%s*$") then
return true
end
if line:match("}%s*from%s+['\"]") then
return true
end
if line:match("^%s*}%s*;?%s*$") then
return true
end
if line:match(";%s*$") then
return true
end
elseif filetype == "python" or filetype == "py" then
-- Python single-line import: doesn't end with \, (, or ,
-- Examples: "from typing import List, Dict" or "import os"
if not line:match("\\%s*$") and not line:match("%(%s*$") and not line:match(",%s*$") then
return true
end
-- Python multiline imports end with closing paren
if line:match("%)%s*$") then
return true
end
elseif filetype == "go" then
-- Go multi-line imports end with ')'
if line:match("%)%s*$") then
return true
end
elseif filetype == "rust" or filetype == "rs" then
-- Rust use statements end with ';'
if line:match(";%s*$") then
return true
end
end
return false
return utils.ends_multiline_import(line, filetype)
end
--- Parse code into imports and body
@@ -285,7 +150,7 @@ function M.find_import_section(bufnr, filetype)
if is_multi and not ends_multiline_import(line, filetype) then
in_multiline = true
end
elseif is_empty_or_comment(line, filetype) then
elseif utils.is_empty_or_comment(line, filetype) then
-- Allow gaps in import section
if first_import then
consecutive_non_import = consecutive_non_import + 1
@@ -388,34 +253,11 @@ function M.sort_imports(imports, filetype)
local local_imports = {}
for _, imp in ipairs(imports) do
-- Detect import type based on patterns
local is_local = false
local is_builtin = false
local category = utils.classify_import(imp, filetype)
if filetype == "javascript" or filetype == "typescript" or filetype == "ts" or filetype == "tsx" then
-- Local: starts with . or ..
is_local = imp:match("from%s+['\"]%.") or imp:match("require%(['\"]%.")
-- Node builtin modules
is_builtin = imp:match("from%s+['\"]node:") or imp:match("from%s+['\"]fs['\"]")
or imp:match("from%s+['\"]path['\"]") or imp:match("from%s+['\"]http['\"]")
elseif filetype == "python" or filetype == "py" then
-- Local: relative imports
is_local = imp:match("^from%s+%.") or imp:match("^import%s+%.")
-- Python stdlib (simplified check)
is_builtin = imp:match("^import%s+os") or imp:match("^import%s+sys")
or imp:match("^from%s+os%s+") or imp:match("^from%s+sys%s+")
or imp:match("^import%s+re") or imp:match("^import%s+json")
elseif filetype == "lua" then
-- Local: relative requires
is_local = imp:match("require%(['\"]%.") or imp:match("require%s+['\"]%.")
elseif filetype == "go" then
-- Local: project imports (contain /)
is_local = imp:match("['\"][^'\"]+/[^'\"]+['\"]") and not imp:match("github%.com")
end
if is_builtin then
if category == "builtin" then
table.insert(builtin, imp)
elseif is_local then
elseif category == "local" then
table.insert(local_imports, imp)
else
table.insert(third_party, imp)
@@ -533,7 +375,7 @@ function M.inject(bufnr, code, opts)
local trimmed = line:match("^%s*(.-)%s*$")
-- Skip shebang, docstrings, and initial comments
if trimmed ~= "" and not trimmed:match("^#!")
and not trimmed:match("^['\"]") and not is_empty_or_comment(line, filetype) then
and not trimmed:match("^['\"]") and not utils.is_empty_or_comment(line, filetype) then
insert_at = i - 1
break
end

View File

@@ -5,7 +5,7 @@
local M = {}
local parser = require("codetyper.parser")
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Get list of files for completion
---@param prefix string Prefix to filter files

View File

@@ -6,8 +6,8 @@
local M = {}
local utils = require("codetyper.utils")
local scanner = require("codetyper.indexer.scanner")
local utils = require("codetyper.support.utils")
local scanner = require("codetyper.features.indexer.scanner")
--- Language-specific query patterns for Tree-sitter
local TS_QUERIES = {

View File

@@ -6,7 +6,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Index schema version for migrations
local INDEX_VERSION = 1
@@ -194,8 +194,8 @@ end
---@param callback? fun(index: ProjectIndex)
---@return ProjectIndex|nil
function M.index_project(callback)
local scanner = require("codetyper.indexer.scanner")
local analyzer = require("codetyper.indexer.analyzer")
local scanner = require("codetyper.features.indexer.scanner")
local analyzer = require("codetyper.features.indexer.analyzer")
local index = create_empty_index()
local root = utils.get_project_root()
@@ -256,7 +256,7 @@ function M.index_project(callback)
M.save_index(index)
-- Store memories
local memory = require("codetyper.indexer.memory")
local memory = require("codetyper.features.indexer.memory")
memory.store_index_summary(index)
-- Sync project summary to brain
@@ -331,8 +331,8 @@ end
---@param filepath string
---@return FileIndex|nil
function M.index_file(filepath)
local analyzer = require("codetyper.indexer.analyzer")
local memory = require("codetyper.indexer.memory")
local analyzer = require("codetyper.features.indexer.analyzer")
local memory = require("codetyper.features.indexer.memory")
local root = utils.get_project_root()
if not root then
@@ -475,7 +475,7 @@ function M.schedule_index_file(filepath)
end
-- Check if file should be indexed
local scanner = require("codetyper.indexer.scanner")
local scanner = require("codetyper.features.indexer.scanner")
if not scanner.should_index(filepath, config) then
return
end
@@ -496,7 +496,7 @@ end
---@param opts {file: string, intent: table|nil, prompt: string, scope: string|nil}
---@return table Context information
function M.get_context_for(opts)
local memory = require("codetyper.indexer.memory")
local memory = require("codetyper.features.indexer.memory")
local index = M.load_index()
local context = {

View File

@@ -6,7 +6,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Memory directories
local MEMORIES_DIR = "memories"

View File

@@ -5,7 +5,7 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Project type markers
local PROJECT_MARKERS = {

View File

@@ -22,16 +22,16 @@ function M.setup(opts)
return
end
local config = require("codetyper.config")
local config = require("codetyper.config.defaults")
M.config = config.setup(opts)
-- Initialize modules
local commands = require("codetyper.commands")
local gitignore = require("codetyper.gitignore")
local autocmds = require("codetyper.autocmds")
local tree = require("codetyper.tree")
local completion = require("codetyper.completion")
local logs_panel = require("codetyper.logs_panel")
local commands = require("codetyper.adapters.nvim.commands")
local gitignore = require("codetyper.support.gitignore")
local autocmds = require("codetyper.adapters.nvim.autocmds")
local tree = require("codetyper.support.tree")
local completion = require("codetyper.features.completion.inline")
local logs_panel = require("codetyper.adapters.nvim.ui.logs_panel")
-- Register commands
commands.setup()
@@ -53,25 +53,25 @@ function M.setup(opts)
-- Initialize project indexer if enabled
if M.config.indexer and M.config.indexer.enabled then
local indexer = require("codetyper.indexer")
local indexer = require("codetyper.features.indexer")
indexer.setup(M.config.indexer)
end
-- Initialize brain learning system if enabled
if M.config.brain and M.config.brain.enabled then
local brain = require("codetyper.brain")
local brain = require("codetyper.core.memory")
brain.setup(M.config.brain)
end
-- Setup inline ghost text suggestions (Copilot-style)
if M.config.suggestion and M.config.suggestion.enabled then
local suggestion = require("codetyper.suggestion")
local suggestion = require("codetyper.features.completion.suggestion")
suggestion.setup(M.config.suggestion)
end
-- Start the event-driven scheduler if enabled
if M.config.scheduler and M.config.scheduler.enabled then
local scheduler = require("codetyper.agent.scheduler")
local scheduler = require("codetyper.core.scheduler.scheduler")
scheduler.start(M.config.scheduler)
end
@@ -80,7 +80,7 @@ function M.setup(opts)
-- Auto-open Ask panel after a short delay (to let UI settle)
if M.config.auto_open_ask then
vim.defer_fn(function()
local ask = require("codetyper.ask")
local ask = require("codetyper.features.ask.engine")
if not ask.is_open() then
ask.open()
end

View File

@@ -2,14 +2,14 @@
local M = {}
local utils = require("codetyper.utils")
local utils = require("codetyper.support.utils")
--- Inject generated code into target file
---@param target_path string Path to target file
---@param code string Generated code
---@param prompt_type string Type of prompt (refactor, add, document, etc.)
function M.inject_code(target_path, code, prompt_type)
local window = require("codetyper.window")
local window = require("codetyper.adapters.nvim.windows")
-- Normalize the target path
target_path = vim.fn.fnamemodify(target_path, ":p")
@@ -109,7 +109,7 @@ function M.inject_add(bufnr, code)
local lines = vim.split(code, "\n", { plain = true })
-- Get cursor position in target window
local window = require("codetyper.window")
local window = require("codetyper.adapters.nvim.windows")
local target_win = window.get_target_win()
local insert_line

View File

@@ -0,0 +1,35 @@
M.params = {
{
name = "command",
description = "The shell command to execute",
type = "string",
},
{
name = "cwd",
description = "Working directory for the command (optional)",
type = "string",
optional = true,
},
{
name = "timeout",
description = "Timeout in milliseconds (default: 120000)",
type = "integer",
optional = true,
},
}
M.returns = {
{
name = "stdout",
description = "Command output",
type = "string",
},
{
name = "error",
description = "Error message if command failed",
type = "string",
optional = true,
},
}
return M

View File

@@ -0,0 +1,40 @@
---@mod codetyper.params.agents.confidence Parameters for confidence scoring
local M = {}
--- Heuristic weights (must sum to 1.0)
M.weights = {
length = 0.15, -- Response length relative to prompt
uncertainty = 0.30, -- Uncertainty phrases
syntax = 0.25, -- Syntax completeness
repetition = 0.15, -- Duplicate lines
truncation = 0.15, -- Incomplete ending
}
--- Uncertainty phrases that indicate low confidence
M.uncertainty_phrases = {
-- English
"i'm not sure",
"i am not sure",
"maybe",
"perhaps",
"might work",
"could work",
"not certain",
"uncertain",
"i think",
"possibly",
"TODO",
"FIXME",
"XXX",
"placeholder",
"implement this",
"fill in",
"your code here",
"...", -- Ellipsis as placeholder
"# TODO",
"// TODO",
"-- TODO",
"/* TODO",
}
return M

View File

@@ -0,0 +1,33 @@
---@mod codetyper.params.agents.conflict Parameters for conflict resolution
local M = {}
--- Configuration defaults
M.config = {
-- Run linter check after accepting AI suggestions
lint_after_accept = true,
-- Auto-fix lint errors without prompting
auto_fix_lint_errors = true,
-- Auto-show menu after injecting conflict
auto_show_menu = true,
-- Auto-show menu for next conflict after resolving one
auto_show_next_menu = true,
}
--- Highlight groups
M.hl_groups = {
current = "CoderConflictCurrent",
current_label = "CoderConflictCurrentLabel",
incoming = "CoderConflictIncoming",
incoming_label = "CoderConflictIncomingLabel",
separator = "CoderConflictSeparator",
hint = "CoderConflictHint",
}
--- Conflict markers
M.markers = {
current_start = "<<<<<<< CURRENT",
separator = "=======",
incoming_end = ">>>>>>> INCOMING",
}
return M

View File

@@ -0,0 +1,48 @@
---@mod codetyper.params.agents.context Parameters for context building
local M = {}
--- Common ignore patterns
M.ignore_patterns = {
"^%.", -- Hidden files/dirs
"node_modules",
"%.git$",
"__pycache__",
"%.pyc$",
"target", -- Rust
"build",
"dist",
"%.o$",
"%.a$",
"%.so$",
"%.min%.",
"%.map$",
}
--- Key files that are important for understanding the project
M.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",
}
--- Project type detection indicators
M.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" },
}
return M

View File

@@ -0,0 +1,33 @@
M.params = {
{
name = "path",
description = "Path to the file to edit",
type = "string",
},
{
name = "old_string",
description = "Text to find and replace (empty string to create new file or append)",
type = "string",
},
{
name = "new_string",
description = "Text to replace with",
type = "string",
},
}
M.returns = {
{
name = "success",
description = "Whether the edit was applied",
type = "boolean",
},
{
name = "error",
description = "Error message if edit failed",
type = "string",
optional = true,
},
}
return M

View File

@@ -0,0 +1,10 @@
M.description = [[Searches for a pattern in files using ripgrep.
Returns file paths and matching lines. Use this to find code by content.
Example patterns:
- "function foo" - Find function definitions
- "import.*react" - Find React imports
- "TODO|FIXME" - Find todo comments]]
return M

View File

@@ -0,0 +1,161 @@
---@mod codetyper.params.agents.intent Intent patterns and scope configuration
local M = {}
--- Intent patterns with associated metadata
M.intent_patterns = {
-- Complete: fill in missing implementation
complete = {
patterns = {
"complete",
"finish",
"implement",
"fill in",
"fill out",
"stub",
"todo",
"fixme",
},
scope_hint = "function",
action = "replace",
priority = 1,
},
-- Refactor: rewrite existing code
refactor = {
patterns = {
"refactor",
"rewrite",
"restructure",
"reorganize",
"clean up",
"cleanup",
"simplify",
"improve",
},
scope_hint = "function",
action = "replace",
priority = 2,
},
-- Fix: repair bugs or issues
fix = {
patterns = {
"fix",
"repair",
"correct",
"debug",
"solve",
"resolve",
"patch",
"bug",
"error",
"issue",
"update",
"modify",
"change",
"adjust",
"tweak",
},
scope_hint = "function",
action = "replace",
priority = 1,
},
-- Add: insert new code
add = {
patterns = {
"add",
"create",
"insert",
"include",
"append",
"new",
"generate",
"write",
},
scope_hint = nil, -- Could be anywhere
action = "insert",
priority = 3,
},
-- Document: add documentation
document = {
patterns = {
"document",
"comment",
"jsdoc",
"docstring",
"describe",
"annotate",
"type hint",
"typehint",
},
scope_hint = "function",
action = "replace", -- Replace with documented version
priority = 2,
},
-- Test: generate tests
test = {
patterns = {
"test",
"spec",
"unit test",
"integration test",
"coverage",
},
scope_hint = "file",
action = "append",
priority = 3,
},
-- Optimize: improve performance
optimize = {
patterns = {
"optimize",
"performance",
"faster",
"efficient",
"speed up",
"reduce",
"minimize",
},
scope_hint = "function",
action = "replace",
priority = 2,
},
-- Explain: provide explanation (no code change)
explain = {
patterns = {
"explain",
"what does",
"how does",
"why",
"describe",
"walk through",
"understand",
},
scope_hint = "function",
action = "none",
priority = 4,
},
}
--- Scope hint patterns
M.scope_patterns = {
["this function"] = "function",
["this method"] = "function",
["the function"] = "function",
["the method"] = "function",
["this class"] = "class",
["the class"] = "class",
["this file"] = "file",
["the file"] = "file",
["this block"] = "block",
["the block"] = "block",
["this"] = nil, -- Use Tree-sitter to determine
["here"] = nil,
}
return M

View File

@@ -0,0 +1,87 @@
---@mod codetyper.params.agents.languages Language-specific patterns and configurations
local M = {}
--- Language-specific import patterns
M.import_patterns = {
-- JavaScript/TypeScript
javascript = {
{ pattern = "^%s*import%s+.+%s+from%s+['\"]", multi_line = true },
{ pattern = "^%s*import%s+['\"]", multi_line = false },
{ pattern = "^%s*import%s*{", multi_line = true },
{ pattern = "^%s*import%s*%*", multi_line = true },
{ pattern = "^%s*export%s+{.+}%s+from%s+['\"]", multi_line = true },
{ pattern = "^%s*const%s+%w+%s*=%s*require%(['\"]", multi_line = false },
{ pattern = "^%s*let%s+%w+%s*=%s*require%(['\"]", multi_line = false },
{ pattern = "^%s*var%s+%w+%s*=%s*require%(['\"]", multi_line = false },
},
-- Python
python = {
{ pattern = "^%s*import%s+%w", multi_line = false },
{ pattern = "^%s*from%s+[%w%.]+%s+import%s+", multi_line = true },
},
-- Lua
lua = {
{ pattern = "^%s*local%s+%w+%s*=%s*require%s*%(?['\"]", multi_line = false },
{ pattern = "^%s*require%s*%(?['\"]", multi_line = false },
},
-- Go
go = {
{ pattern = "^%s*import%s+%(?", multi_line = true },
},
-- Rust
rust = {
{ pattern = "^%s*use%s+", multi_line = true },
{ pattern = "^%s*extern%s+crate%s+", multi_line = false },
},
-- C/C++
c = {
{ pattern = "^%s*#include%s*[<\"]", multi_line = false },
},
-- Java/Kotlin
java = {
{ pattern = "^%s*import%s+", multi_line = false },
},
-- Ruby
ruby = {
{ pattern = "^%s*require%s+['\"]", multi_line = false },
{ pattern = "^%s*require_relative%s+['\"]", multi_line = false },
},
-- PHP
php = {
{ pattern = "^%s*use%s+", multi_line = false },
{ pattern = "^%s*require%s+['\"]", multi_line = false },
{ pattern = "^%s*require_once%s+['\"]", multi_line = false },
{ pattern = "^%s*include%s+['\"]", multi_line = false },
{ pattern = "^%s*include_once%s+['\"]", multi_line = false },
},
}
-- Alias common extensions to language configs
M.import_patterns.ts = M.import_patterns.javascript
M.import_patterns.tsx = M.import_patterns.javascript
M.import_patterns.jsx = M.import_patterns.javascript
M.import_patterns.mjs = M.import_patterns.javascript
M.import_patterns.cjs = M.import_patterns.javascript
M.import_patterns.py = M.import_patterns.python
M.import_patterns.cpp = M.import_patterns.c
M.import_patterns.hpp = M.import_patterns.c
M.import_patterns.h = M.import_patterns.c
M.import_patterns.kt = M.import_patterns.java
M.import_patterns.rs = M.import_patterns.rust
M.import_patterns.rb = M.import_patterns.ruby
--- Language-specific comment patterns
M.comment_patterns = {
lua = { "^%-%-" },
python = { "^#" },
javascript = { "^//", "^/%*", "^%*" },
typescript = { "^//", "^/%*", "^%*" },
go = { "^//", "^/%*", "^%*" },
rust = { "^//", "^/%*", "^%*" },
c = { "^//", "^/%*", "^%*", "^#" },
java = { "^//", "^/%*", "^%*" },
ruby = { "^#" },
php = { "^//", "^/%*", "^%*", "^#" },
}
return M

View File

@@ -0,0 +1,15 @@
---@mod codetyper.params.agents.linter Linter configuration
local M = {}
M.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,
}
return M

View File

@@ -0,0 +1,36 @@
---@mod codetyper.params.agents.logs Log parameters
local M = {}
M.icons = {
start = "->",
success = "OK",
error = "ERR",
approval = "??",
approved = "YES",
rejected = "NO",
}
M.level_icons = {
info = "i",
debug = ".",
request = ">",
response = "<",
tool = "T",
error = "!",
warning = "?",
success = "i",
queue = "Q",
patch = "P",
}
M.thinking_types = { "thinking", "reason", "action", "task", "result" }
M.thinking_prefixes = {
thinking = "",
reason = "",
action = "",
task = "",
result = "",
}
return M

View File

@@ -0,0 +1,15 @@
---@mod codetyper.params.agents.parser Parser regex patterns
local M = {}
M.patterns = {
fenced_json = "```json%s*(%b{})%s*```",
inline_json = '(%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%})',
}
M.defaults = {
stop_reason = "end_turn",
tool_stop_reason = "tool_use",
replacement_text = "[Tool call]",
}
return M

View File

@@ -0,0 +1,12 @@
---@mod codetyper.params.agents.patch Patch configuration
local M = {}
M.config = {
snapshot_range = 5, -- Lines above/below prompt to snapshot
clean_interval_ms = 60000, -- Check for stale patches every minute
max_age_ms = 3600000, -- 1 hour TTL
staleness_check = true,
use_search_replace_parser = true, -- Enable new parsing logic
}
return M

View File

@@ -0,0 +1,47 @@
---@mod codetyper.params.agents.permissions Dangerous and safe command patterns
local M = {}
--- Dangerous command patterns that should never be auto-allowed
M.dangerous_patterns = {
"^rm%s+%-rf",
"^rm%s+%-r%s+/",
"^rm%s+/",
"^sudo%s+rm",
"^chmod%s+777",
"^chmod%s+%-R",
"^chown%s+%-R",
"^dd%s+",
"^mkfs",
"^fdisk",
"^format",
":.*>%s*/dev/",
"^curl.*|.*sh",
"^wget.*|.*sh",
"^eval%s+",
"`;.*`",
"%$%(.*%)",
"fork%s*bomb",
}
--- Safe command patterns that can be auto-allowed
M.safe_patterns = {
"^ls%s",
"^ls$",
"^cat%s",
"^head%s",
"^tail%s",
"^grep%s",
"^find%s",
"^pwd$",
"^echo%s",
"^wc%s",
"^git%s+status",
"^git%s+diff",
"^git%s+log",
"^git%s+show",
"^git%s+branch",
"^git%s+checkout",
"^git%s+add", -- Generally safe if reviewing changes
}
return M

View File

@@ -0,0 +1,14 @@
---@mod codetyper.params.agents.scheduler Scheduler configuration
local M = {}
M.config = {
enabled = true,
ollama_scout = true,
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
apply_delay_ms = 5000, -- Wait before applying code
remote_provider = "copilot", -- Default fallback provider
}
return M

View File

@@ -0,0 +1,72 @@
---@mod codetyper.params.agents.scope Tree-sitter scope mappings
local M = {}
--- Node types that represent function-like scopes per language
M.function_nodes = {
-- Lua
["function_declaration"] = "function",
["function_definition"] = "function",
["local_function"] = "function",
["function"] = "function",
-- JavaScript/TypeScript
["function_declaration"] = "function",
["function_expression"] = "function",
["arrow_function"] = "function",
["method_definition"] = "method",
["function"] = "function",
-- Python
["function_definition"] = "function",
["lambda"] = "function",
-- Go
["function_declaration"] = "function",
["method_declaration"] = "method",
["func_literal"] = "function",
-- Rust
["function_item"] = "function",
["closure_expression"] = "function",
-- C/C++
["function_definition"] = "function",
["lambda_expression"] = "function",
-- Java
["method_declaration"] = "method",
["constructor_declaration"] = "method",
["lambda_expression"] = "function",
-- Ruby
["method"] = "method",
["singleton_method"] = "method",
["lambda"] = "function",
["block"] = "function",
-- PHP
["function_definition"] = "function",
["method_declaration"] = "method",
["arrow_function"] = "function",
}
--- Node types that represent class-like scopes
M.class_nodes = {
["class_declaration"] = "class",
["class_definition"] = "class",
["struct_declaration"] = "class",
["impl_item"] = "class", -- Rust config
["interface_declaration"] = "class",
["trait_item"] = "class",
}
--- Node types that represent block scopes
M.block_nodes = {
["block"] = "block",
["do_statement"] = "block", -- Lua
["if_statement"] = "block",
["for_statement"] = "block",
["while_statement"] = "block",
}
return M

Some files were not shown because too many files have changed in this diff Show More