11 Commits

Author SHA1 Message Date
5493a5ec38 test: add unit tests for preferences module
- Test default values and loading preferences
- Test saving and persistence to .coder/preferences.json
- Test get/set individual preference values
- Test is_auto_process_enabled and has_asked_auto_process
- Test toggle_auto_process behavior
- Test cache management (clear_cache)
- Handle invalid JSON gracefully

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:53:21 -05:00
c3da2901c9 feat: add user preference system for auto/manual tag processing
- Add preferences.lua module for managing per-project preferences
  - Stores preferences in .coder/preferences.json
  - Shows floating dialog to ask user on first /@ @/ tag
  - Supports toggle between auto/manual modes

- Update autocmds.lua with preference-aware wrapper functions
  - check_for_closed_prompt_with_preference()
  - check_all_prompts_with_preference()
  - Only auto-process when user chose automatic mode

- Add CoderAutoToggle and CoderAutoSet commands
  - Toggle between automatic and manual modes
  - Set mode directly with :CoderAutoSet auto|manual

- Fix completion.lua to work in directories outside project
  - Use current file's directory as base when editing files
    outside cwd (e.g., ~/.config/* files)
  - Search in both current dir and cwd for completions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:51:17 -05:00
46672f6f87 feat: add function completion, apply delay, and VimLeavePre cleanup
Major improvements to the event-driven prompt processing system:

Function Completion:
- Override intent to "complete" when prompt is inside function/method scope
- Use Tree-sitter to detect enclosing scope and replace entire function
- Special LLM prompt instructs to complete function body without duplicating
- Patch apply uses "replace" strategy for scope range instead of appending

Apply Delay:
- Add `apply_delay_ms` config option (default 5000ms) for code review time
- Log "Code ready. Applying in X seconds..." before applying patches
- Configurable wait time before removing tags and injecting code

VimLeavePre Cleanup:
- Logs panel and queue windows close automatically on Neovim exit
- Context modal closes on VimLeavePre
- Scheduler stops timer and cleans up augroup on exit
- Handle QuitPre for :qa, :wqa commands
- Force close with buffer deletion for robust cleanup

Response Cleaning:
- Remove LLM special tokens (deepseek, llama markers)
- Add blank line spacing before appended code
- Log full raw LLM response in logs panel for debugging

Documentation:
- Add required dependencies (plenary.nvim, nvim-treesitter)
- Add optional dependencies (nvim-treesitter-textobjects, nui.nvim)
- Document all intent types including "complete"
- Add Logs Panel section with features and keymaps
- Update lazy.nvim example with dependencies

Tests:
- Add tests for patch create_from_event with different strategies
- Fix assert.is_true to assert.is_truthy for string.match

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 23:40:13 -05:00
0600144768 fixing the issues on the tags 2026-01-13 23:16:27 -05:00
fbd88993e7 Adding test cases 2026-01-13 22:18:32 -05:00
6b25aef917 fixing configuration to change the windows 2026-01-13 22:07:02 -05:00
8a3ee81c3f feat: add event-driven architecture with scope resolution
- Add event queue system (queue.lua) with priority-based processing
- Add patch system (patch.lua) with staleness detection via changedtick
- Add confidence scoring (confidence.lua) with 5 weighted heuristics
- Add async worker wrapper (worker.lua) with timeout handling
- Add scheduler (scheduler.lua) with completion-aware injection
- Add Tree-sitter scope resolution (scope.lua) for functions/methods/classes
- Add intent detection (intent.lua) for complete/refactor/fix/add/etc
- Add tag precedence rules (first tag in scope wins)
- Update autocmds to emit events instead of direct processing
- Add scheduler config options (ollama_scout, escalation_threshold)
- Update prompts with scope-aware context
- Update README with emojis and new features
- Update documentation (llms.txt, CHANGELOG.md, doc/codetyper.txt)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:55:44 -05:00
6268a57498 adding claude.lua 2026-01-13 21:02:45 -05:00
6b71c76517 style: normalize indentation in claude.lua
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:57:03 -05:00
73c56d2f6d feat: add real-time logs panel for /@ @/ code generation
- Add logs_panel.lua module for standalone logs display
- Add logging to generate() functions in claude.lua and ollama.lua
- Show logs panel automatically when running transform commands
- Log request/response with token counting for both providers
- Add :CoderLogs command to toggle logs panel manually
- Clean up duplicate generate_with_tools function in claude.lua

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:54:26 -05:00
2989fb5f14 feat: add agent mode and CoderType command for mode switching
- Add agent module with tool execution support (read_file, edit_file, bash)
- Add agent/ui.lua with chat sidebar, input area, and real-time logs panel
- Add agent/logs.lua for token counting and request/response logging
- Add generate_with_tools to claude.lua and ollama.lua for tool use
- Add chat_switcher.lua modal picker for Ask/Agent mode selection
- Add CoderType command to show mode switcher (replaces C-Tab keymaps)
- Update ask.lua and agent/ui.lua headers to reference :CoderType

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:45:55 -05:00
55 changed files with 13910 additions and 1178 deletions

48
.gitignore vendored
View File

@@ -1,7 +1,49 @@
# Codetyper.nvim - AI coding partner files
*.coder.*
.coder/
.claude/
Makefile
/@
add gitignore for lua files
/@
# Created by https://www.toptal.com/developers/gitignore/api/lua
### Lua ###
# Compiled Lua sources
luac.out
# luarocks build files
*.src.rock
*.zip
*.tar.gz
# Object files
*.o
*.os
*.ko
*.obj
*.elf
# Precompiled Headers
*.gch
*.pch
# Libraries
*.lib
*.a
*.la
*.lo
*.def
*.exp
# Shared objects (inc. Windows DLLs)
*.dll
*.so
*.so.*
*.dylib
# Executables
*.exe
*.out
*.app
*.i*86
*.x86_64
*.hex

View File

@@ -7,9 +7,116 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] - 2026-01-13
### Added
- **Event-Driven Architecture** - Complete rewrite of prompt processing system
- Prompts are now treated as events with metadata (buffer state, priority, timestamps)
- New modules: `queue.lua`, `patch.lua`, `confidence.lua`, `worker.lua`, `scheduler.lua`
- Priority-based event queue with observer pattern
- Buffer snapshots for staleness detection
- **Optimistic Execution** - Ollama as fast local scout
- Use Ollama for first attempt (fast local inference)
- Automatically escalate to remote LLM if confidence is low
- Configurable escalation threshold (default: 0.7)
- **Confidence Scoring** - Response quality heuristics
- 5 weighted heuristics: length, uncertainty phrases, syntax completeness, 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
### Configuration
New `scheduler` configuration block:
```lua
scheduler = {
enabled = true, -- Enable event-driven mode
ollama_scout = true, -- Use Ollama first
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
}
```
---
## [0.3.0] - 2026-01-13
### Added
- **Multiple LLM Providers** - Support for additional providers beyond Claude and Ollama
- OpenAI API with custom endpoint support (Azure, OpenRouter, etc.)
- Google Gemini API
- GitHub Copilot (uses existing copilot.lua/copilot.vim authentication)
- **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
- 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)
- **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)
- **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
- Improved code generation prompts to explicitly request only raw code output (no explanations, markdown, or code fences)
- Window width configuration now uses percentage as whole number (e.g., `25` for 25%)
- Improved code extraction from LLM responses
- Better prompt templates for code generation
### Fixed
- Window width calculation consistency across modules
---
## [0.2.0] - 2026-01-11
@@ -87,6 +194,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fixed** - Bug fixes
- **Security** - Vulnerability fixes
[Unreleased]: https://github.com/cargdev/codetyper.nvim/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/cargdev/codetyper.nvim/compare/v0.4.0...HEAD
[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
[0.2.0]: https://github.com/cargdev/codetyper.nvim/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/cargdev/codetyper.nvim/releases/tag/v0.1.0

704
README.md
View File

@@ -7,29 +7,36 @@
## ✨ Features
- **🪟 Split View**: Work with your code and prompts side by side
- **💬 Ask Panel**: Chat interface for questions and explanations (like avante.nvim)
- **🏷️ Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts
- **🤖 Multiple LLM Providers**: Support for Claude API and Ollama (local)
- **📝 Smart Injection**: Automatically detects prompt type (refactor, add, document)
- **🔒 Git Integration**: Automatically adds `.coder.*` files and `.coder/` folder to `.gitignore`
- **🌳 Project Tree Logging**: Automatically maintains a `tree.log` tracking your project structure
- **⚡ Lazy Loading**: Only loads when you need it
- 📐 **Split View**: Work with your code and prompts side by side
- 💬 **Ask Panel**: Chat interface for questions and explanations
- 🤖 **Agent Mode**: Autonomous coding agent with tool use (read, edit, write, bash)
- 🏷️ **Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts
- **Transform Commands**: Transform prompts inline without leaving your file
- 🔌 **Multiple LLM Providers**: Claude, OpenAI, Gemini, Copilot, and Ollama (local)
- 📋 **Event-Driven Scheduler**: Queue-based processing with optimistic execution
- 🎯 **Tree-sitter Scope Resolution**: Smart context extraction for functions/methods
- 🧠 **Intent Detection**: Understands complete, refactor, fix, add, document intents
- 📊 **Confidence Scoring**: Automatic escalation from local to remote LLMs
- 🛡️ **Completion-Aware**: Safe injection that doesn't fight with autocomplete
- 📁 **Auto-Index**: Automatically create coder companion files on file open
- 📜 **Logs Panel**: Real-time visibility into LLM requests and token usage
- 🔒 **Git Integration**: Automatically adds `.coder.*` files to `.gitignore`
- 🌳 **Project Tree Logging**: Maintains a `tree.log` tracking your project structure
---
## 📋 Table of Contents
## 📚 Table of Contents
- [Requirements](#-requirements)
- [Installation](#-installation)
- [Quick Start](#-quick-start)
- [Configuration](#%EF%B8%8F-configuration)
- [Configuration](#-configuration)
- [LLM Providers](#-llm-providers)
- [Commands Reference](#-commands-reference)
- [Usage Guide](#-usage-guide)
- [How It Works](#%EF%B8%8F-how-it-works)
- [Keymaps](#-keymaps-suggested)
- [Agent Mode](#-agent-mode)
- [Keymaps](#-keymaps)
- [Health Check](#-health-check)
- [Contributing](#-contributing)
---
@@ -37,7 +44,17 @@
- Neovim >= 0.8.0
- curl (for API calls)
- Claude API key **OR** Ollama running locally
- One of: Claude API key, OpenAI API key, Gemini API key, GitHub Copilot, or Ollama running locally
### Required Plugins
- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) - Async utilities
- [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) - Scope detection for functions/methods
### Optional Plugins
- [nvim-treesitter-textobjects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects) - Better text object support
- [nui.nvim](https://github.com/MunifTanjim/nui.nvim) - UI components
---
@@ -48,16 +65,22 @@
```lua
{
"cargdev/codetyper.nvim",
cmd = { "Coder", "CoderOpen", "CoderToggle" },
dependencies = {
"nvim-lua/plenary.nvim", -- Required: async utilities
"nvim-treesitter/nvim-treesitter", -- Required: scope detection
"nvim-treesitter/nvim-treesitter-textobjects", -- Optional: text objects
"MunifTanjim/nui.nvim", -- Optional: UI components
},
cmd = { "Coder", "CoderOpen", "CoderToggle", "CoderAgent" },
keys = {
{ "<leader>co", "<cmd>Coder open<cr>", desc = "Coder: Open" },
{ "<leader>ct", "<cmd>Coder toggle<cr>", desc = "Coder: Toggle" },
{ "<leader>cp", "<cmd>Coder process<cr>", desc = "Coder: Process" },
{ "<leader>ca", "<cmd>CoderAgentToggle<cr>", desc = "Coder: Agent" },
},
config = function()
require("codetyper").setup({
llm = {
provider = "claude", -- or "ollama"
provider = "claude", -- or "openai", "gemini", "copilot", "ollama"
},
})
end,
@@ -93,8 +116,6 @@ using regex, return boolean @/
**3. The LLM generates code and injects it into `utils.ts` (right panel)**
That's it! You're now coding with AI assistance. 🎉
---
## ⚙️ Configuration
@@ -103,37 +124,67 @@ That's it! You're now coding with AI assistance. 🎉
require("codetyper").setup({
-- LLM Provider Configuration
llm = {
provider = "claude", -- "claude" or "ollama"
provider = "claude", -- "claude", "openai", "gemini", "copilot", or "ollama"
-- Claude (Anthropic) settings
claude = {
api_key = nil, -- Uses ANTHROPIC_API_KEY env var if nil
model = "claude-sonnet-4-20250514",
},
-- OpenAI settings
openai = {
api_key = nil, -- Uses OPENAI_API_KEY env var if nil
model = "gpt-4o",
endpoint = nil, -- Custom endpoint (Azure, OpenRouter, etc.)
},
-- Google Gemini settings
gemini = {
api_key = nil, -- Uses GEMINI_API_KEY env var if nil
model = "gemini-2.0-flash",
},
-- GitHub Copilot settings (uses copilot.lua/copilot.vim auth)
copilot = {
model = "gpt-4o",
},
-- Ollama (local) settings
ollama = {
host = "http://localhost:11434",
model = "codellama",
model = "deepseek-coder:6.7b",
},
},
-- Window Configuration
window = {
width = 0.25, -- 25% of screen width (1/4) for Ask panel
position = "left", -- "left" or "right"
border = "rounded", -- Border style for floating windows
width = 25, -- Percentage of screen width (25 = 25%)
position = "left",
border = "rounded",
},
-- Prompt Tag Patterns
patterns = {
open_tag = "/@", -- Tag to start a prompt
close_tag = "@/", -- Tag to end a prompt
open_tag = "/@",
close_tag = "@/",
file_pattern = "*.coder.*",
},
-- Auto Features
auto_gitignore = true, -- Automatically add coder files to .gitignore
auto_gitignore = true, -- Automatically add coder files to .gitignore
auto_open_ask = true, -- Auto-open Ask panel on startup
auto_index = false, -- Auto-create coder companion files on file open
-- Event-Driven Scheduler
scheduler = {
enabled = true, -- Enable event-driven prompt processing
ollama_scout = true, -- Use Ollama for first attempt (fast local)
escalation_threshold = 0.7, -- Below this confidence, escalate to remote
max_concurrent = 2, -- Max parallel workers
completion_delay_ms = 100, -- Delay injection after completion popup
apply_delay_ms = 5000, -- Wait before applying code (ms), allows review
},
})
```
@@ -141,334 +192,281 @@ require("codetyper").setup({
| Variable | Description |
|----------|-------------|
| `ANTHROPIC_API_KEY` | Your Claude API key (if not set in config) |
| `ANTHROPIC_API_KEY` | Claude API key |
| `OPENAI_API_KEY` | OpenAI API key |
| `GEMINI_API_KEY` | Google Gemini API key |
---
## 📜 Commands Reference
## 🔌 LLM Providers
### Main Command
### Claude (Anthropic)
Best for complex reasoning and code generation.
```lua
llm = {
provider = "claude",
claude = { model = "claude-sonnet-4-20250514" },
}
```
### OpenAI
Supports custom endpoints for Azure, OpenRouter, etc.
```lua
llm = {
provider = "openai",
openai = {
model = "gpt-4o",
endpoint = "https://api.openai.com/v1/chat/completions", -- optional
},
}
```
### Google Gemini
Fast and capable.
```lua
llm = {
provider = "gemini",
gemini = { model = "gemini-2.0-flash" },
}
```
### GitHub Copilot
Uses your existing Copilot subscription (requires copilot.lua or copilot.vim).
```lua
llm = {
provider = "copilot",
copilot = { model = "gpt-4o" },
}
```
### Ollama (Local)
Run models locally with no API costs.
```lua
llm = {
provider = "ollama",
ollama = {
host = "http://localhost:11434",
model = "deepseek-coder:6.7b",
},
}
```
---
## 📝 Commands Reference
### Main Commands
| Command | Description |
|---------|-------------|
| `:Coder {subcommand}` | Main command with subcommands below |
| `:Coder {subcommand}` | Main command with subcommands |
| `:CoderOpen` | Open the coder split view |
| `:CoderClose` | Close the coder split view |
| `:CoderToggle` | Toggle the coder split view |
| `:CoderProcess` | Process the last prompt |
### Subcommands
### Ask Panel
| Subcommand | Alias | Description |
|------------|-------|-------------|
| `open` | `:CoderOpen` | Open the coder split view for current file |
| `close` | `:CoderClose` | Close the coder split view |
| `toggle` | `:CoderToggle` | Toggle the coder split view on/off |
| `process` | `:CoderProcess` | Process the last prompt and generate code |
| `status` | - | Show plugin status and project statistics |
| `focus` | - | Switch focus between coder and target windows |
| `tree` | `:CoderTree` | Manually refresh the tree.log file |
| `tree-view` | `:CoderTreeView` | Open tree.log in a readonly split |
| `ask` | `:CoderAsk` | Open the Ask panel for questions |
| `ask-toggle` | `:CoderAskToggle` | Toggle the Ask panel |
| `ask-clear` | `:CoderAskClear` | Clear Ask chat history |
| Command | Description |
|---------|-------------|
| `:CoderAsk` | Open the Ask panel |
| `:CoderAskToggle` | Toggle the Ask panel |
| `:CoderAskClear` | Clear chat history |
---
### Agent Mode
### Command Details
| Command | Description |
|---------|-------------|
| `:CoderAgent` | Open the Agent panel |
| `:CoderAgentToggle` | Toggle the Agent panel |
| `:CoderAgentStop` | Stop the running agent |
#### `:Coder open` / `:CoderOpen`
### Transform Commands
Opens a split view with:
- **Left panel**: The coder file (`*.coder.*`) where you write prompts
- **Right panel**: The target file where generated code is injected
| Command | Description |
|---------|-------------|
| `:CoderTransform` | Transform all /@ @/ tags in file |
| `:CoderTransformCursor` | Transform tag at cursor position |
| `:CoderTransformVisual` | Transform selected tags (visual mode) |
```vim
" If you have index.ts open:
:Coder open
" Creates/opens index.coder.ts on the left
```
### Utility Commands
**Behavior:**
- If no file is in buffer, opens a file picker (Telescope if available)
- Creates the coder file if it doesn't exist
- Automatically sets the correct filetype for syntax highlighting
---
#### `:Coder close` / `:CoderClose`
Closes the coder split view, keeping only your target file open.
```vim
:Coder close
```
---
#### `:Coder toggle` / `:CoderToggle`
Toggles the coder view on or off. Useful for quick switching.
```vim
:Coder toggle
```
---
#### `:Coder process` / `:CoderProcess`
Processes the last completed prompt in the coder file and sends it to the LLM.
```vim
" After writing a prompt and closing with @/
:Coder process
```
**What happens:**
1. Finds the last `/@...@/` prompt in the coder buffer
2. Detects the prompt type (refactor, add, document, etc.)
3. Sends it to the configured LLM with file context
4. Injects the generated code into the target file
---
#### `:Coder status`
Displays current plugin status including:
- LLM provider and configuration
- API key status (configured/not set)
- Window settings
- Project statistics (files, directories)
- Tree log path
```vim
:Coder status
```
---
#### `:Coder focus`
Switches focus between the coder window and target window.
```vim
:Coder focus
" Press again to switch back
```
---
#### `:Coder tree` / `:CoderTree`
Manually refreshes the `.coder/tree.log` file with current project structure.
```vim
:Coder tree
```
> Note: The tree is automatically updated on file save/create/delete.
---
#### `:Coder tree-view` / `:CoderTreeView`
Opens the tree.log file in a readonly split for viewing your project structure.
```vim
:Coder tree-view
```
---
#### `:Coder ask` / `:CoderAsk`
Opens the **Ask panel** - a chat interface similar to avante.nvim for asking questions about your code, getting explanations, or general programming help.
```vim
:Coder ask
```
**The Ask Panel Layout:**
```
┌───────────────────┬─────────────────────────────────────────┐
│ 💬 Chat (output) │ │
│ │ Your code file │
│ ┌─ 👤 You ──── │ │
│ │ What is this? │ │
│ │ │
│ ┌─ 🤖 AI ───── │ │
│ │ This is... │ │
├───────────────────┤ │
│ ✏️ Input │ │
│ Type question... │ │
└───────────────────┴─────────────────────────────────────────┘
(1/4 width) (3/4 width)
```
> **Note:** The Ask panel is fixed at 1/4 (25%) of the screen width.
**Ask Panel Keymaps:**
| Key | Mode | Description |
|-----|------|-------------|
| `@` | Insert | Attach/reference a file |
| `Ctrl+Enter` | Insert/Normal | Submit question |
| `Ctrl+n` | Insert/Normal | Start new chat (clear all) |
| `Ctrl+f` | Insert/Normal | Add current file as context |
| `Ctrl+h/j/k/l` | Normal/Insert | Navigate between windows |
| `q` | Normal | Close panel (closes both windows) |
| `K` / `J` | Normal | Jump between output/input |
| `Y` | Normal | Copy last response to clipboard |
---
#### `:Coder ask-toggle` / `:CoderAskToggle`
Toggles the Ask panel on or off.
```vim
:Coder ask-toggle
```
---
#### `:Coder ask-clear` / `:CoderAskClear`
Clears the Ask panel chat history.
```vim
:Coder ask-clear
```
| Command | Description |
|---------|-------------|
| `:CoderIndex` | Open coder companion for current file |
| `:CoderLogs` | Toggle logs panel |
| `:CoderType` | Switch between Ask/Agent modes |
| `:CoderTree` | Refresh tree.log |
| `:CoderTreeView` | View tree.log |
---
## 📖 Usage Guide
### Step 1: Open Your Project File
### Tag-Based Prompts
Open any source file you want to work with:
Write prompts in your coder file using `/@` and `@/` tags:
```vim
:e src/components/Button.tsx
```
### Step 2: Start Coder View
```vim
:Coder open
```
This creates a split:
```
┌─────────────────────────┬─────────────────────────┐
│ Button.coder.tsx │ Button.tsx │
│ (write prompts here) │ (your actual code) │
└─────────────────────────┴─────────────────────────┘
```
### Step 3: Write Your Prompt
In the coder file (left), write your prompt using tags:
```tsx
```typescript
/@ Create a Button component with the following props:
- variant: 'primary' | 'secondary' | 'danger'
- size: 'sm' | 'md' | 'lg'
- disabled: boolean
- onClick: function
Use Tailwind CSS for styling @/
```
### Step 4: Process the Prompt
When you close the tag with `@/`, the prompt is automatically processed.
When you close the tag with `@/`, you'll be prompted to process. Or manually:
### Transform Commands
```vim
:Coder process
Transform prompts inline without the split view:
```typescript
// In your source file:
/@ Add input validation for email and password @/
// Run :CoderTransformCursor to transform the prompt at cursor
```
### Step 5: Review Generated Code
The generated code appears in your target file (right panel). Review, edit if needed, and save!
---
### Prompt Types
The plugin automatically detects what you want based on keywords:
The plugin auto-detects prompt type:
| Keywords | Type | Behavior |
|----------|------|----------|
| `refactor`, `rewrite`, `change` | Refactor | Replaces code in target file |
| `add`, `create`, `implement`, `new` | Add | Inserts code at cursor position |
| `document`, `comment`, `jsdoc` | Document | Adds documentation above code |
| `explain`, `what`, `how` | Explain | Shows explanation (no injection) |
| *(other)* | Generic | Prompts you for injection method |
| `complete`, `finish`, `implement`, `todo` | Complete | Completes function body (replaces scope) |
| `refactor`, `rewrite`, `simplify` | Refactor | Replaces code |
| `fix`, `debug`, `bug`, `error` | Fix | Fixes bugs (replaces scope) |
| `add`, `create`, `generate` | Add | Inserts new code |
| `document`, `comment`, `jsdoc` | Document | Adds documentation |
| `optimize`, `performance`, `faster` | Optimize | Optimizes code (replaces scope) |
| `explain`, `what`, `how` | Explain | Shows explanation only |
### Function Completion
When you write a prompt **inside** a function body, the plugin uses Tree-sitter to detect the enclosing scope and automatically switches to "complete" mode:
```typescript
function getUserById(id: number): User | null {
/@ return the user from the database by id, handle not found case @/
}
```
The LLM will complete the function body while keeping the exact same signature. The entire function scope is replaced with the completed version.
---
### Prompt Examples
## 📊 Logs Panel
#### Creating New Functions
The logs panel provides real-time visibility into LLM operations:
```typescript
/@ Create an async function fetchUsers that:
- Takes a page number and limit as parameters
- Fetches from /api/users endpoint
- Returns typed User[] array
- Handles errors gracefully @/
### Features
- **Generation Logs**: Shows all LLM requests, responses, and token usage
- **Queue Display**: Shows pending and processing prompts
- **Full Response View**: Complete LLM responses are logged for debugging
- **Auto-cleanup**: Logs panel and queue windows automatically close when exiting Neovim
### Opening the Logs Panel
```vim
:CoderLogs
```
#### Refactoring Code
The logs panel opens automatically when processing prompts with the scheduler enabled.
```typescript
/@ Refactor the handleSubmit function to:
- Use async/await instead of .then()
- Add proper TypeScript types
- Extract validation logic into separate function @/
```
### Keymaps
#### Adding Documentation
| Key | Description |
|-----|-------------|
| `q` | Close logs panel |
| `<Esc>` | Close logs panel |
```typescript
/@ Add JSDoc documentation to all exported functions
including @param, @returns, and @example tags @/
```
---
#### Implementing Patterns
## 🤖 Agent Mode
```typescript
/@ Implement the singleton pattern for DatabaseConnection class
with lazy initialization and thread safety @/
```
The Agent mode provides an autonomous coding assistant with tool access:
#### Adding Tests
### Available Tools
```typescript
/@ Create unit tests for the calculateTotal function
using Jest, cover edge cases:
- Empty array
- Negative numbers
- Large numbers @/
- **read_file**: Read file contents
- **edit_file**: Edit files with find/replace
- **write_file**: Create or overwrite files
- **bash**: Execute shell commands
### Using Agent Mode
1. Open the agent panel: `:CoderAgent` or `<leader>ca`
2. Describe what you want to accomplish
3. The agent will use tools to complete the task
4. Review changes before they're applied
### Agent Keymaps
| Key | Description |
|-----|-------------|
| `<CR>` | Submit message |
| `Ctrl+c` | Stop agent execution |
| `q` | Close agent panel |
---
## ⌨️ Keymaps
### Default Keymaps (auto-configured)
| Key | Mode | Description |
|-----|------|-------------|
| `<leader>ctt` | Normal | Transform tag at cursor |
| `<leader>ctt` | Visual | Transform selected tags |
| `<leader>ctT` | Normal | Transform all tags in file |
| `<leader>ca` | Normal | Toggle Agent panel |
| `<leader>ci` | Normal | Open coder companion (index) |
### Ask Panel Keymaps
| Key | Description |
|-----|-------------|
| `@` | Attach/reference a file |
| `Ctrl+Enter` | Submit question |
| `Ctrl+n` | Start new chat |
| `Ctrl+f` | Add current file as context |
| `q` | Close panel |
| `Y` | Copy last response |
### Suggested Additional Keymaps
```lua
local map = vim.keymap.set
map("n", "<leader>co", "<cmd>Coder open<cr>", { desc = "Coder: Open" })
map("n", "<leader>cc", "<cmd>Coder close<cr>", { desc = "Coder: Close" })
map("n", "<leader>ct", "<cmd>Coder toggle<cr>", { desc = "Coder: Toggle" })
map("n", "<leader>cp", "<cmd>Coder process<cr>", { desc = "Coder: Process" })
map("n", "<leader>cs", "<cmd>Coder status<cr>", { desc = "Coder: Status" })
```
---
## 🏗️ How It Works
## 🏥 Health Check
```
┌─────────────────────────────────────────────────────────────────┐
│ Neovim │
├────────────────────────────┬────────────────────────────────────┤
│ src/api.coder.ts │ src/api.ts │
│ │ │
│ /@ Create a REST client │ // Generated code appears here │
│ class with methods for │ export class RestClient { │
│ GET, POST, PUT, DELETE │ async get<T>(url: string) { │
│ with TypeScript │ // ... │
│ generics @/ │ } │
│ │ } │
└────────────────────────────┴────────────────────────────────────┘
Verify your setup:
```vim
:checkhealth codetyper
```
### File Structure
This checks:
- Neovim version
- curl availability
- LLM configuration
- API key status
- Telescope availability (optional)
---
## 📁 File Structure
```
your-project/
@@ -477,105 +475,9 @@ your-project/
├── src/
│ ├── index.ts # Your source file
│ ├── index.coder.ts # Coder file (gitignored)
│ ├── utils.ts
│ └── utils.coder.ts
└── .gitignore # Auto-updated with coder patterns
```
### The Flow
1. **You write prompts** in `*.coder.*` files using `/@...@/` tags
2. **Plugin detects** when you close a prompt tag
3. **Context is gathered** from the target file (content, language, etc.)
4. **LLM generates** code based on your prompt and context
5. **Code is injected** into the target file based on prompt type
6. **You review and save** - you're always in control!
### Project Tree Logging
The `.coder/tree.log` file is automatically maintained:
```
# Project Tree: my-project
# Generated: 2026-01-11 15:30:45
# By: Codetyper.nvim
📦 my-project
├── 📁 src
│ ├── 📘 index.ts
│ ├── 📘 utils.ts
│ └── 📁 components
│ └── ⚛️ Button.tsx
├── 📋 package.json
└── 📝 README.md
```
Updated automatically when you:
- Create new files
- Save files
- Delete files
---
## 🔑 Keymaps (Suggested)
Add these to your Neovim config:
```lua
-- Codetyper keymaps
local map = vim.keymap.set
-- Coder view
map("n", "<leader>co", "<cmd>Coder open<cr>", { desc = "Coder: Open view" })
map("n", "<leader>cc", "<cmd>Coder close<cr>", { desc = "Coder: Close view" })
map("n", "<leader>ct", "<cmd>Coder toggle<cr>", { desc = "Coder: Toggle view" })
map("n", "<leader>cp", "<cmd>Coder process<cr>", { desc = "Coder: Process prompt" })
map("n", "<leader>cs", "<cmd>Coder status<cr>", { desc = "Coder: Show status" })
map("n", "<leader>cf", "<cmd>Coder focus<cr>", { desc = "Coder: Switch focus" })
map("n", "<leader>cv", "<cmd>Coder tree-view<cr>", { desc = "Coder: View tree" })
-- Ask panel
map("n", "<leader>ca", "<cmd>Coder ask<cr>", { desc = "Coder: Open Ask" })
map("n", "<leader>cA", "<cmd>Coder ask-toggle<cr>", { desc = "Coder: Toggle Ask" })
map("n", "<leader>cx", "<cmd>Coder ask-clear<cr>", { desc = "Coder: Clear Ask" })
```
Or with [which-key.nvim](https://github.com/folke/which-key.nvim):
```lua
local wk = require("which-key")
wk.register({
["<leader>c"] = {
name = "+coder",
o = { "<cmd>Coder open<cr>", "Open view" },
c = { "<cmd>Coder close<cr>", "Close view" },
t = { "<cmd>Coder toggle<cr>", "Toggle view" },
p = { "<cmd>Coder process<cr>", "Process prompt" },
s = { "<cmd>Coder status<cr>", "Show status" },
f = { "<cmd>Coder focus<cr>", "Switch focus" },
v = { "<cmd>Coder tree-view<cr>", "View tree" },
},
})
```
---
## 🔧 Health Check
Verify your setup is correct:
```vim
:checkhealth codetyper
```
This checks:
- ✅ Neovim version
- ✅ curl availability
- ✅ LLM configuration
- ✅ API key status
- ✅ Telescope availability (optional)
- ✅ Gitignore configuration
---
## 🤝 Contributing
@@ -590,13 +492,13 @@ MIT License - see [LICENSE](LICENSE) for details.
---
## 👤 Author
## 👨‍💻 Author
**cargdev**
- 🌐 Website: [cargdev.io](https://cargdev.io)
- 📝 Blog: [blog.cargdev.io](https://blog.cargdev.io)
- 📧 Email: carlos.gutierrez@carg.dev
- Website: [cargdev.io](https://cargdev.io)
- Blog: [blog.cargdev.io](https://blog.cargdev.io)
- Email: carlos.gutierrez@carg.dev
---

View File

@@ -11,34 +11,41 @@ CONTENTS *codetyper-contents*
2. Requirements ............................ |codetyper-requirements|
3. Installation ............................ |codetyper-installation|
4. Configuration ........................... |codetyper-configuration|
5. Usage ................................... |codetyper-usage|
6. Commands ................................ |codetyper-commands|
7. Workflow ................................ |codetyper-workflow|
8. API ..................................... |codetyper-api|
5. LLM Providers ........................... |codetyper-providers|
6. Usage ................................... |codetyper-usage|
7. Commands ................................ |codetyper-commands|
8. Agent Mode .............................. |codetyper-agent|
9. Transform Commands ...................... |codetyper-transform|
10. Keymaps ................................ |codetyper-keymaps|
11. API .................................... |codetyper-api|
==============================================================================
1. INTRODUCTION *codetyper-introduction*
Codetyper.nvim is an AI-powered coding partner that helps you write code
faster using LLM APIs (Claude, Ollama) with a unique workflow.
Instead of generating files directly, Codetyper watches what you type in
special `.coder.*` files and generates code when you close prompt tags.
faster using LLM APIs with a unique workflow.
Key features:
- Split view with coder file and target file side by side
- Prompt-based code generation using /@ ... @/ tags
- Support for Claude and Ollama LLM providers
- Automatic .gitignore management for coder files and .coder/ folder
- Intelligent code injection based on prompt type
- Automatic project tree logging in .coder/tree.log
- Support for Claude, OpenAI, Gemini, Copilot, and Ollama providers
- Agent mode with autonomous tool use (read, edit, write, bash)
- Transform commands for inline prompt processing
- Auto-index feature for automatic companion file creation
- Automatic .gitignore management
- Real-time logs panel with token usage tracking
==============================================================================
2. REQUIREMENTS *codetyper-requirements*
- Neovim >= 0.8.0
- curl (for API calls)
- Claude API key (if using Claude) or Ollama running locally
- One of:
- Claude API key (ANTHROPIC_API_KEY)
- OpenAI API key (OPENAI_API_KEY)
- Gemini API key (GEMINI_API_KEY)
- GitHub Copilot (via copilot.lua or copilot.vim)
- Ollama running locally
==============================================================================
3. INSTALLATION *codetyper-installation*
@@ -50,10 +57,7 @@ Using lazy.nvim: >lua
config = function()
require("codetyper").setup({
llm = {
provider = "claude", -- or "ollama"
claude = {
api_key = vim.env.ANTHROPIC_API_KEY,
},
provider = "claude", -- or "openai", "gemini", "copilot", "ollama"
},
})
end,
@@ -75,19 +79,31 @@ Default configuration: >lua
require("codetyper").setup({
llm = {
provider = "claude", -- "claude" or "ollama"
provider = "claude", -- "claude", "openai", "gemini", "copilot", "ollama"
claude = {
api_key = nil, -- Uses ANTHROPIC_API_KEY env var if nil
model = "claude-sonnet-4-20250514",
},
openai = {
api_key = nil, -- Uses OPENAI_API_KEY env var if nil
model = "gpt-4o",
endpoint = nil, -- Custom endpoint (Azure, OpenRouter, etc.)
},
gemini = {
api_key = nil, -- Uses GEMINI_API_KEY env var if nil
model = "gemini-2.0-flash",
},
copilot = {
model = "gpt-4o", -- Uses OAuth from copilot.lua/copilot.vim
},
ollama = {
host = "http://localhost:11434",
model = "codellama",
model = "deepseek-coder:6.7b",
},
},
window = {
width = 0.4, -- 40% of screen width
position = "left", -- "left" or "right"
width = 25, -- Percentage of screen width (25 = 25%)
position = "left",
border = "rounded",
},
patterns = {
@@ -96,10 +112,67 @@ Default configuration: >lua
file_pattern = "*.coder.*",
},
auto_gitignore = true,
auto_open_ask = true,
auto_index = false, -- Auto-create coder companion files
})
<
==============================================================================
5. USAGE *codetyper-usage*
5. LLM PROVIDERS *codetyper-providers*
*codetyper-claude*
Claude (Anthropic)~
Best for complex reasoning and code generation.
>lua
llm = {
provider = "claude",
claude = { model = "claude-sonnet-4-20250514" },
}
<
*codetyper-openai*
OpenAI~
Supports custom endpoints for Azure, OpenRouter, etc.
>lua
llm = {
provider = "openai",
openai = {
model = "gpt-4o",
endpoint = nil, -- optional custom endpoint
},
}
<
*codetyper-gemini*
Google Gemini~
Fast and capable.
>lua
llm = {
provider = "gemini",
gemini = { model = "gemini-2.0-flash" },
}
<
*codetyper-copilot*
GitHub Copilot~
Uses your existing Copilot subscription.
Requires copilot.lua or copilot.vim to be configured.
>lua
llm = {
provider = "copilot",
copilot = { model = "gpt-4o" },
}
<
*codetyper-ollama*
Ollama (Local)~
Run models locally with no API costs.
>lua
llm = {
provider = "ollama",
ollama = {
host = "http://localhost:11434",
model = "deepseek-coder:6.7b",
},
}
<
==============================================================================
6. USAGE *codetyper-usage*
1. Open any file (e.g., `index.ts`)
2. Run `:Coder open` to create/open the corresponding coder file
@@ -113,8 +186,17 @@ Default configuration: >lua
- Generate the code
- Inject it into the target file
Prompt Types~
The plugin detects the type of request from your prompt:
- "refactor" / "rewrite" - Modifies existing code
- "add" / "create" / "implement" - Adds new code
- "document" / "comment" - Adds documentation
- "explain" - Provides explanations (no code injection)
==============================================================================
6. COMMANDS *codetyper-commands*
7. COMMANDS *codetyper-commands*
*:Coder*
:Coder [subcommand]
@@ -143,8 +225,55 @@ Default configuration: >lua
*:CoderProcess*
:CoderProcess
Process the last prompt in the current coder buffer and
inject generated code into the target file.
Process the last prompt in the current coder buffer.
*:CoderAsk*
:CoderAsk
Open the Ask panel for questions and explanations.
*:CoderAskToggle*
:CoderAskToggle
Toggle the Ask panel.
*:CoderAskClear*
:CoderAskClear
Clear Ask panel chat history.
*:CoderAgent*
:CoderAgent
Open the Agent panel for autonomous coding tasks.
*:CoderAgentToggle*
:CoderAgentToggle
Toggle the Agent panel.
*:CoderAgentStop*
:CoderAgentStop
Stop the currently running agent.
*:CoderTransform*
:CoderTransform
Transform all /@ @/ tags in the current file.
*:CoderTransformCursor*
:CoderTransformCursor
Transform the /@ @/ tag at cursor position.
*:CoderTransformVisual*
:CoderTransformVisual
Transform selected /@ @/ tags (visual mode).
*:CoderIndex*
:CoderIndex
Open coder companion file for current source file.
*:CoderLogs*
:CoderLogs
Toggle the logs panel showing LLM request details.
*:CoderType*
:CoderType
Show mode switcher UI (Ask/Agent).
*:CoderTree*
:CoderTree
@@ -155,54 +284,83 @@ Default configuration: >lua
Open the tree.log file in a vertical split for viewing.
==============================================================================
7. WORKFLOW *codetyper-workflow*
8. AGENT MODE *codetyper-agent*
The Coder Workflow~
Agent mode provides an autonomous coding assistant with tool access.
1. Target File: Your actual source file (e.g., `src/utils.ts`)
2. Coder File: A companion file (e.g., `src/utils.coder.ts`)
Available Tools~
The coder file mirrors your target file's location and extension.
When you write prompts in the coder file and close them, the
generated code appears in the target file.
- read_file Read file contents at a path
- edit_file Edit files with find/replace
- write_file Create or overwrite files
- bash Execute shell commands
Prompt Types~
Using Agent Mode~
The plugin detects the type of request from your prompt:
1. Open the agent panel: `:CoderAgent` or `<leader>ca`
2. Describe what you want to accomplish
3. The agent will use tools to complete the task
4. Review changes before they're applied
- "refactor" - Modifies existing code
- "add" / "create" / "implement" - Adds new code
- "document" / "comment" - Adds documentation
- "explain" - Provides explanations (no code injection)
Agent Keymaps~
Example Prompts~
>
/@ Refactor this function to use async/await @/
/@ Add input validation to the form handler @/
/@ Add JSDoc comments to all exported functions @/
/@ Create a React hook for managing form state
with validation support @/
<
Project Tree Logging~
Codetyper automatically maintains a .coder/ folder with a tree.log file:
>
.coder/
└── tree.log # Auto-updated project structure
<
The tree.log is updated whenever you:
- Create a new file
- Save a file
- Delete a file
- Change directories
View the tree anytime with `:Coder tree-view` or refresh with `:Coder tree`.
<CR> Submit message
Ctrl+c Stop agent execution
q Close agent panel
==============================================================================
8. API *codetyper-api*
9. TRANSFORM COMMANDS *codetyper-transform*
Transform commands allow you to process /@ @/ tags inline without
opening the split view.
*:CoderTransform*
:CoderTransform
Find and transform all /@ @/ tags in the current buffer.
Each tag is replaced with generated code.
*:CoderTransformCursor*
:CoderTransformCursor
Transform the /@ @/ tag at the current cursor position.
Useful for processing a single prompt.
*:CoderTransformVisual*
:'<,'>CoderTransformVisual
Transform /@ @/ tags within the visual selection.
Select lines containing tags and run this command.
Example~
>
// In your source file:
/@ Add input validation for email @/
// After running :CoderTransformCursor:
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
<
==============================================================================
10. KEYMAPS *codetyper-keymaps*
Default keymaps (auto-configured):
<leader>ctt (Normal) Transform tag at cursor
<leader>ctt (Visual) Transform selected tags
<leader>ctT (Normal) Transform all tags in file
<leader>ca (Normal) Toggle Agent panel
<leader>ci (Normal) Open coder companion (index)
Ask Panel keymaps:
@ Attach/reference a file
Ctrl+Enter Submit question
Ctrl+n Start new chat
Ctrl+f Add current file as context
q Close panel
Y Copy last response
==============================================================================
11. API *codetyper-api*
*codetyper.setup()*
codetyper.setup({opts})

346
llms.txt
View File

@@ -4,7 +4,7 @@
## Overview
Codetyper.nvim is a Neovim plugin written in Lua that acts as an AI-powered coding partner. It integrates with LLM APIs (Claude, Ollama) to help developers write code faster using a unique prompt-based workflow.
Codetyper.nvim is a Neovim plugin written in Lua that acts as an AI-powered coding partner. It integrates with multiple LLM APIs (Claude, OpenAI, Gemini, Copilot, Ollama) to help developers write code faster using a unique prompt-based workflow.
## Core Concept
@@ -27,15 +27,39 @@ lua/codetyper/
├── 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 and .coder/ folder
├── autocmds.lua # Autocommands for tag detection, filetype, tree updates
├── 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)
── llm/
├── init.lua # LLM interface, provider selection
├── claude.lua # Claude API client (Anthropic)
── ollama.lua # Ollama API client (local LLMs)
── logs_panel.lua # Standalone logs panel UI
├── 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)
├── 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
├── ask/
│ ├── 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
```
## .coder/ Folder
@@ -47,51 +71,240 @@ The plugin automatically creates and maintains a `.coder/` folder in your projec
└── tree.log # Project structure, auto-updated on file changes
```
The `tree.log` contains:
- Project name and timestamp
- Full directory tree with file type icons
- Automatically ignores: hidden files, node_modules, .git, build folders, coder files
## Key Features
Tree updates are triggered by:
- `BufWritePost` - When files are saved
- `BufNewFile` - When new files are created
- `BufDelete` - When files are deleted
- `DirChanged` - When changing directories
### 1. Multiple LLM Providers
Updates are debounced (1 second) to prevent excessive writes.
## Key Functions
### Setup
```lua
require("codetyper").setup({
llm = { provider = "claude" | "ollama", ... },
window = { width = 0.4, position = "left" },
patterns = { open_tag = "/@", close_tag = "@/" },
auto_gitignore = true,
})
llm = {
provider = "claude", -- "claude", "openai", "gemini", "copilot", "ollama"
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" },
}
```
### Commands
### 2. Agent Mode
Autonomous coding assistant with tool access:
- `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. Event-Driven Scheduler
Prompts are treated as events, not commands:
```
User types /@...@/ → Event queued → Scheduler dispatches → Worker processes → Patch created → Safe injection
```
**Key concepts:**
- **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
**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
}
```
### 7. Tree-sitter Scope Resolution
Prompts automatically resolve to their enclosing function/method/class:
```lua
function foo()
/@ complete this function @/ -- Resolves to `foo`
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.
### 8. Intent Detection
The system parses prompts to detect user intent:
| Intent | Keywords | Action |
|--------|----------|--------|
| complete | complete, finish, implement | replace |
| refactor | refactor, rewrite, simplify | replace |
| fix | fix, repair, debug, bug | replace |
| 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 |
### 9. Tag Precedence
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
## Commands
### Main Commands
- `:Coder open` - Opens split view with coder file
- `:Coder close` - Closes the split
- `:Coder toggle` - Toggles the view
- `:Coder process` - Manually triggers code generation
- `:Coder status` - Shows configuration status and project stats
- `:Coder tree` - Manually refresh tree.log
- `:Coder tree-view` - Open tree.log in split view
### Prompt Tags
- Opening tag: `/@`
- Closing tag: `@/`
- Content between tags is the prompt sent to LLM
### Ask Panel
- `:CoderAsk` - Open Ask panel
- `:CoderAskToggle` - Toggle Ask panel
- `:CoderAskClear` - Clear chat history
### Prompt Types (Auto-detected)
- `refactor` - Modifies existing code
- `add` - Adds new code at cursor/end
- `document` - Adds documentation/comments
- `explain` - Explanations (no code injection)
- `generic` - User chooses injection method
### Agent Mode
- `:CoderAgent` - Open Agent panel
- `:CoderAgentToggle` - Toggle Agent panel
- `:CoderAgentStop` - Stop running agent
### Transform
- `:CoderTransform` - Transform all tags
- `:CoderTransformCursor` - Transform at cursor
- `:CoderTransformVisual` - Transform selection
### Utility
- `:CoderIndex` - Open coder companion
- `:CoderLogs` - Toggle logs panel
- `:CoderType` - Switch Ask/Agent mode
- `:CoderTree` - Refresh tree.log
- `:CoderTreeView` - View tree.log
## 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",
},
},
window = {
width = 25, -- percentage (25 = 25% of screen)
position = "left", -- "left" | "right"
border = "rounded",
},
patterns = {
open_tag = "/@",
close_tag = "@/",
file_pattern = "*.coder.*",
},
auto_gitignore = true,
auto_open_ask = true,
auto_index = false, -- auto-create coder companion files
scheduler = {
enabled = true, -- enable event-driven scheduler
ollama_scout = true, -- use Ollama as fast scout
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
},
}
```
## LLM Integration
### Claude API
- Endpoint: `https://api.anthropic.com/v1/messages`
- Uses `x-api-key` header for authentication
- Supports tool use for agent mode
### 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
### Gemini API
- Endpoint: `https://generativelanguage.googleapis.com/v1beta/models`
- Uses API key in URL parameter
- Supports function calling for agent mode
### 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
## Agent Tool Definitions
```lua
tools = {
read_file = { path: string },
edit_file = { path: string, find: string, replace: string },
write_file = { path: string, content: string },
bash = { command: string, timeout?: number },
}
```
## 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
@@ -103,62 +316,13 @@ require("codetyper").setup({
Pattern: `name.coder.extension`
## Configuration Schema
```lua
{
llm = {
provider = "claude", -- "claude" | "ollama"
claude = {
api_key = nil, -- string, uses ANTHROPIC_API_KEY env if nil
model = "claude-sonnet-4-20250514",
},
ollama = {
host = "http://localhost:11434",
model = "codellama",
},
},
window = {
width = 0.4, -- number (percentage if <=1, columns if >1)
position = "left", -- "left" | "right"
border = "rounded", -- border style for floating windows
},
patterns = {
open_tag = "/@", -- string
close_tag = "@/", -- string
file_pattern = "*.coder.*",
},
auto_gitignore = true, -- boolean
}
```
## LLM Integration
### Claude API
- Endpoint: `https://api.anthropic.com/v1/messages`
- Uses `x-api-key` header for authentication
- Requires `anthropic-version: 2023-06-01` header
### Ollama API
- Endpoint: `{host}/api/generate`
- No authentication required for local instances
- Health check via `/api/tags` endpoint
## 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 (replace/insert/append/clipboard)
## Dependencies
- **Required**: Neovim >= 0.8.0, curl
- **Optional**: telescope.nvim (enhanced file picker)
- **Optional**: telescope.nvim (enhanced file picker), copilot.lua or copilot.vim (for Copilot provider)
## Contact
- Author: cargdev
- Email: carlos.gutierrez@carg.dev
- Website: https://cargdev.io
- Blog: https://blog.cargdev.io

View File

@@ -0,0 +1,328 @@
---@mod codetyper.agent.confidence Response confidence scoring
---@brief [[
--- Scores LLM responses using heuristics to decide if escalation is needed.
--- Returns 0.0-1.0 where higher = more confident the response is good.
---@brief ]]
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
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",
}
--- Score based on response length relative to prompt
---@param response string
---@param prompt string
---@return number 0.0-1.0
local function score_length(response, prompt)
local response_len = #response
local prompt_len = #prompt
-- Very short response to long prompt is suspicious
if prompt_len > 50 and response_len < 20 then
return 0.2
end
-- Response should generally be longer than prompt for code generation
local ratio = response_len / math.max(prompt_len, 1)
if ratio < 0.5 then
return 0.3
elseif ratio < 1.0 then
return 0.6
elseif ratio < 2.0 then
return 0.8
else
return 1.0
end
end
--- Score based on uncertainty phrases
---@param response string
---@return number 0.0-1.0
local function score_uncertainty(response)
local lower = response:lower()
local found = 0
for _, phrase in ipairs(uncertainty_phrases) do
if lower:find(phrase:lower(), 1, true) then
found = found + 1
end
end
-- More uncertainty phrases = lower score
if found == 0 then
return 1.0
elseif found == 1 then
return 0.7
elseif found == 2 then
return 0.5
else
return 0.2
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
local function score_syntax(response)
local score = 1.0
-- Check bracket balance
if not check_brackets(response) then
score = score - 0.4
end
-- Check for common incomplete patterns
-- Lua: unbalanced end/function
local function_count = select(2, response:gsub("function%s*%(", ""))
+ select(2, response:gsub("function%s+%w+%(", ""))
local end_count = select(2, response:gsub("%f[%w]end%f[%W]", ""))
if function_count > end_count + 2 then
score = score - 0.2
end
-- JavaScript/TypeScript: unclosed template literals
local backtick_count = select(2, response:gsub("`", ""))
if backtick_count % 2 ~= 0 then
score = score - 0.2
end
-- String quotes balance
local double_quotes = select(2, response:gsub('"', ""))
local single_quotes = select(2, response:gsub("'", ""))
-- Allow for escaped quotes by being lenient
if double_quotes % 2 ~= 0 and not response:find('\\"') then
score = score - 0.1
end
if single_quotes % 2 ~= 0 and not response:find("\\'") then
score = score - 0.1
end
return math.max(0, score)
end
--- Score based on line repetition
---@param response string
---@return number 0.0-1.0
local function score_repetition(response)
local lines = vim.split(response, "\n", { plain = true })
if #lines < 3 then
return 1.0
end
-- Count duplicate non-empty lines
local seen = {}
local duplicates = 0
for _, line in ipairs(lines) do
local trimmed = vim.trim(line)
if #trimmed > 10 then -- Only check substantial lines
if seen[trimmed] then
duplicates = duplicates + 1
end
seen[trimmed] = true
end
end
local dup_ratio = duplicates / #lines
if dup_ratio < 0.1 then
return 1.0
elseif dup_ratio < 0.2 then
return 0.8
elseif dup_ratio < 0.3 then
return 0.5
else
return 0.2 -- High repetition = degraded output
end
end
--- Score based on truncation indicators
---@param response string
---@return number 0.0-1.0
local function score_truncation(response)
local score = 1.0
-- Ends with ellipsis
if response:match("%.%.%.$") then
score = score - 0.5
end
-- Ends with incomplete comment
if response:match("/%*[^*/]*$") then -- Unclosed /* comment
score = score - 0.4
end
if response:match("<!%-%-[^>]*$") then -- Unclosed HTML comment
score = score - 0.4
end
-- Ends mid-statement (common patterns)
local trimmed = vim.trim(response)
local last_char = trimmed:sub(-1)
-- Suspicious endings
if last_char == "=" or last_char == "," or last_char == "(" then
score = score - 0.3
end
-- Very short last line after long response
local lines = vim.split(response, "\n", { plain = true })
if #lines > 5 then
local last_line = vim.trim(lines[#lines])
if #last_line < 5 and not last_line:match("^[%}%]%)%;end]") then
score = score - 0.2
end
end
return math.max(0, score)
end
---@class ConfidenceBreakdown
---@field length number
---@field uncertainty number
---@field syntax number
---@field repetition number
---@field truncation number
---@field weighted_total number
--- Calculate confidence score for response
---@param response string The LLM response
---@param prompt string The original prompt
---@param context table|nil Additional context (unused for now)
---@return number confidence 0.0-1.0
---@return ConfidenceBreakdown breakdown Individual scores
function M.score(response, prompt, context)
_ = context -- Reserved for future use
if not response or #response == 0 then
return 0, {
length = 0,
uncertainty = 0,
syntax = 0,
repetition = 0,
truncation = 0,
weighted_total = 0,
}
end
local scores = {
length = score_length(response, prompt or ""),
uncertainty = score_uncertainty(response),
syntax = score_syntax(response),
repetition = score_repetition(response),
truncation = score_truncation(response),
}
-- Calculate weighted total
local weighted = 0
for key, weight in pairs(M.weights) do
weighted = weighted + (scores[key] * weight)
end
scores.weighted_total = weighted
return weighted, scores
end
--- Check if response needs escalation
---@param confidence number
---@param threshold number|nil Default: 0.7
---@return boolean needs_escalation
function M.needs_escalation(confidence, threshold)
threshold = threshold or 0.7
return confidence < threshold
end
--- Get human-readable confidence level
---@param confidence number
---@return string
function M.level_name(confidence)
if confidence >= 0.9 then
return "excellent"
elseif confidence >= 0.8 then
return "good"
elseif confidence >= 0.7 then
return "acceptable"
elseif confidence >= 0.5 then
return "uncertain"
else
return "poor"
end
end
--- Format breakdown for logging
---@param breakdown ConfidenceBreakdown
---@return string
function M.format_breakdown(breakdown)
return string.format(
"len:%.2f unc:%.2f syn:%.2f rep:%.2f tru:%.2f = %.2f",
breakdown.length,
breakdown.uncertainty,
breakdown.syntax,
breakdown.repetition,
breakdown.truncation,
breakdown.weighted_total
)
end
return M

View File

@@ -0,0 +1,177 @@
---@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

@@ -0,0 +1,240 @@
---@mod codetyper.agent.diff Diff preview UI for agent changes
---
--- Shows diff previews for file changes and bash command approvals.
local M = {}
--- Show a diff preview for file changes
---@param diff_data table { path: string, original: string, modified: string, operation: string }
---@param callback fun(approved: boolean) Called with user decision
function M.show_diff(diff_data, callback)
local original_lines = vim.split(diff_data.original, "\n", { plain = true })
local modified_lines = vim.split(diff_data.modified, "\n", { plain = true })
-- Calculate window dimensions
local width = math.floor(vim.o.columns * 0.8)
local height = math.floor(vim.o.lines * 0.7)
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
-- Create left buffer (original)
local left_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, original_lines)
vim.bo[left_buf].modifiable = false
vim.bo[left_buf].bufhidden = "wipe"
-- Create right buffer (modified)
local right_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, modified_lines)
vim.bo[right_buf].modifiable = false
vim.bo[right_buf].bufhidden = "wipe"
-- Set filetype for syntax highlighting based on file extension
local ext = vim.fn.fnamemodify(diff_data.path, ":e")
if ext and ext ~= "" then
vim.bo[left_buf].filetype = ext
vim.bo[right_buf].filetype = ext
end
-- Create left window (original)
local half_width = math.floor((width - 1) / 2)
local left_win = vim.api.nvim_open_win(left_buf, true, {
relative = "editor",
width = half_width,
height = height - 2,
row = row,
col = col,
style = "minimal",
border = "rounded",
title = " ORIGINAL ",
title_pos = "center",
})
-- Create right window (modified)
local right_win = vim.api.nvim_open_win(right_buf, false, {
relative = "editor",
width = half_width,
height = height - 2,
row = row,
col = col + half_width + 1,
style = "minimal",
border = "rounded",
title = " MODIFIED [" .. diff_data.operation .. "] ",
title_pos = "center",
})
-- Enable diff mode in both windows
vim.api.nvim_win_call(left_win, function()
vim.cmd("diffthis")
end)
vim.api.nvim_win_call(right_win, function()
vim.cmd("diffthis")
end)
-- Sync scrolling
vim.wo[left_win].scrollbind = true
vim.wo[right_win].scrollbind = true
vim.wo[left_win].cursorbind = true
vim.wo[right_win].cursorbind = true
-- Track if callback was already called
local callback_called = false
-- Close function
local function close_and_respond(approved)
if callback_called then
return
end
callback_called = true
-- Disable diff mode
pcall(function()
vim.api.nvim_win_call(left_win, function()
vim.cmd("diffoff")
end)
end)
pcall(function()
vim.api.nvim_win_call(right_win, function()
vim.cmd("diffoff")
end)
end)
-- Close windows
pcall(vim.api.nvim_win_close, left_win, true)
pcall(vim.api.nvim_win_close, right_win, true)
-- Call callback
vim.schedule(function()
callback(approved)
end)
end
-- Set up keymaps for both buffers
local keymap_opts = { noremap = true, silent = true, nowait = true }
for _, buf in ipairs({ left_buf, right_buf }) do
-- Approve
vim.keymap.set("n", "y", function()
close_and_respond(true)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
vim.keymap.set("n", "<CR>", function()
close_and_respond(true)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
-- Reject
vim.keymap.set("n", "n", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
vim.keymap.set("n", "q", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
vim.keymap.set("n", "<Esc>", function()
close_and_respond(false)
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
-- Switch between windows
vim.keymap.set("n", "<Tab>", function()
local current = vim.api.nvim_get_current_win()
if current == left_win then
vim.api.nvim_set_current_win(right_win)
else
vim.api.nvim_set_current_win(left_win)
end
end, vim.tbl_extend("force", keymap_opts, { buffer = buf }))
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, {})
end
--- Show approval dialog for bash commands
---@param command string The bash command to approve
---@param callback fun(approved: boolean) Called with user decision
function M.show_bash_approval(command, callback)
-- Create a simple floating window for bash approval
local lines = {
"",
" BASH COMMAND APPROVAL",
" " .. string.rep("-", 50),
"",
" Command:",
" $ " .. command,
"",
" " .. string.rep("-", 50),
" Press [y] or [Enter] to execute",
" Press [n], [q], or [Esc] to cancel",
"",
}
local width = math.max(60, #command + 10)
local height = #lines
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = "wipe"
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = width,
height = height,
row = math.floor((vim.o.lines - height) / 2),
col = math.floor((vim.o.columns - width) / 2),
style = "minimal",
border = "rounded",
title = " Approve Command? ",
title_pos = "center",
})
-- Apply some highlighting
vim.api.nvim_buf_add_highlight(buf, -1, "Title", 1, 0, -1)
vim.api.nvim_buf_add_highlight(buf, -1, "String", 5, 0, -1)
local callback_called = false
local function close_and_respond(approved)
if callback_called then
return
end
callback_called = true
pcall(vim.api.nvim_win_close, win, true)
vim.schedule(function()
callback(approved)
end)
end
local keymap_opts = { buffer = buf, noremap = true, silent = true, nowait = true }
-- Approve
vim.keymap.set("n", "y", function()
close_and_respond(true)
end, keymap_opts)
vim.keymap.set("n", "<CR>", function()
close_and_respond(true)
end, keymap_opts)
-- Reject
vim.keymap.set("n", "n", function()
close_and_respond(false)
end, keymap_opts)
vim.keymap.set("n", "q", function()
close_and_respond(false)
end, keymap_opts)
vim.keymap.set("n", "<Esc>", function()
close_and_respond(false)
end, keymap_opts)
end
return M

View File

@@ -0,0 +1,294 @@
---@mod codetyper.agent.executor Tool executor for agent system
---
--- Executes tools requested by the LLM and returns results.
local M = {}
local utils = require("codetyper.utils")
---@class ExecutionResult
---@field success boolean Whether the execution succeeded
---@field result string Result message or content
---@field requires_approval boolean Whether user approval is needed
---@field diff_data? DiffData Data for diff preview (if requires_approval)
---@class DiffData
---@field path string File path
---@field original string Original content
---@field modified string Modified content
---@field operation string Operation type: "edit", "create", "overwrite", "bash"
--- Execute a tool and return result via callback
---@param tool_name string Name of the tool to execute
---@param parameters table Tool parameters
---@param callback fun(result: ExecutionResult) Callback with result
function M.execute(tool_name, parameters, callback)
local handlers = {
read_file = M.handle_read_file,
edit_file = M.handle_edit_file,
write_file = M.handle_write_file,
bash = M.handle_bash,
}
local handler = handlers[tool_name]
if not handler then
callback({
success = false,
result = "Unknown tool: " .. tool_name,
requires_approval = false,
})
return
end
handler(parameters, callback)
end
--- Handle read_file tool
---@param params table { path: string }
---@param callback fun(result: ExecutionResult)
function M.handle_read_file(params, callback)
local path = M.resolve_path(params.path)
local content = utils.read_file(path)
if content then
callback({
success = true,
result = content,
requires_approval = false,
})
else
callback({
success = false,
result = "Could not read file: " .. path,
requires_approval = false,
})
end
end
--- Handle edit_file tool
---@param params table { path: string, find: string, replace: string }
---@param callback fun(result: ExecutionResult)
function M.handle_edit_file(params, callback)
local path = M.resolve_path(params.path)
local original = utils.read_file(path)
if not original then
callback({
success = false,
result = "File not found: " .. path,
requires_approval = false,
})
return
end
-- Try to find and replace the content
local escaped_find = utils.escape_pattern(params.find)
local new_content, count = original:gsub(escaped_find, params.replace, 1)
if count == 0 then
callback({
success = false,
result = "Could not find content to replace in: " .. path,
requires_approval = false,
})
return
end
-- Requires user approval - show diff
callback({
success = true,
result = "Edit prepared for: " .. path,
requires_approval = true,
diff_data = {
path = path,
original = original,
modified = new_content,
operation = "edit",
},
})
end
--- Handle write_file tool
---@param params table { path: string, content: string }
---@param callback fun(result: ExecutionResult)
function M.handle_write_file(params, callback)
local path = M.resolve_path(params.path)
local original = utils.read_file(path) or ""
local operation = original == "" and "create" or "overwrite"
-- Ensure parent directory exists
local dir = vim.fn.fnamemodify(path, ":h")
if dir ~= "" and dir ~= "." then
utils.ensure_dir(dir)
end
callback({
success = true,
result = (operation == "create" and "Create" or "Overwrite") .. " prepared for: " .. path,
requires_approval = true,
diff_data = {
path = path,
original = original,
modified = params.content,
operation = operation,
},
})
end
--- Handle bash tool
---@param params table { command: string, timeout?: number }
---@param callback fun(result: ExecutionResult)
function M.handle_bash(params, callback)
local command = params.command
-- Requires user approval first
callback({
success = true,
result = "Command: " .. command,
requires_approval = true,
diff_data = {
path = "[bash]",
original = "",
modified = "$ " .. command,
operation = "bash",
},
bash_command = command,
bash_timeout = params.timeout or 30000,
})
end
--- Actually apply an approved change
---@param diff_data DiffData The diff data to apply
---@param callback fun(result: ExecutionResult)
function M.apply_change(diff_data, callback)
if diff_data.operation == "bash" then
-- Extract command from modified (remove "$ " prefix)
local command = diff_data.modified:gsub("^%$ ", "")
M.execute_bash_command(command, 30000, callback)
else
-- Write file
local success = utils.write_file(diff_data.path, diff_data.modified)
if success then
-- Reload buffer if it's open
M.reload_buffer_if_open(diff_data.path)
callback({
success = true,
result = "Changes applied to: " .. diff_data.path,
requires_approval = false,
})
else
callback({
success = false,
result = "Failed to write: " .. diff_data.path,
requires_approval = false,
})
end
end
end
--- Execute a bash command
---@param command string Command to execute
---@param timeout number Timeout in milliseconds
---@param callback fun(result: ExecutionResult)
function M.execute_bash_command(command, timeout, callback)
local stdout_data = {}
local stderr_data = {}
local job_id
job_id = vim.fn.jobstart(command, {
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(_, data)
if data then
for _, line in ipairs(data) do
if line ~= "" then
table.insert(stdout_data, line)
end
end
end
end,
on_stderr = function(_, data)
if data then
for _, line in ipairs(data) do
if line ~= "" then
table.insert(stderr_data, line)
end
end
end
end,
on_exit = function(_, exit_code)
vim.schedule(function()
local result = table.concat(stdout_data, "\n")
if #stderr_data > 0 then
if result ~= "" then
result = result .. "\n"
end
result = result .. "STDERR:\n" .. table.concat(stderr_data, "\n")
end
result = result .. "\n[Exit code: " .. exit_code .. "]"
callback({
success = exit_code == 0,
result = result,
requires_approval = false,
})
end)
end,
})
-- Set up timeout
if job_id > 0 then
vim.defer_fn(function()
if vim.fn.jobwait({ job_id }, 0)[1] == -1 then
vim.fn.jobstop(job_id)
vim.schedule(function()
callback({
success = false,
result = "Command timed out after " .. timeout .. "ms",
requires_approval = false,
})
end)
end
end, timeout)
else
callback({
success = false,
result = "Failed to start command",
requires_approval = false,
})
end
end
--- Reload a buffer if it's currently open
---@param filepath string Path to the file
function M.reload_buffer_if_open(filepath)
local full_path = vim.fn.fnamemodify(filepath, ":p")
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf)
if buf_name == full_path then
vim.api.nvim_buf_call(buf, function()
vim.cmd("edit!")
end)
break
end
end
end
end
--- Resolve a path (expand ~ and make absolute if needed)
---@param path string Path to resolve
---@return string Resolved path
function M.resolve_path(path)
-- Expand ~ to home directory
local expanded = vim.fn.expand(path)
-- If relative, make it relative to project root or cwd
if not vim.startswith(expanded, "/") then
local root = utils.get_project_root() or vim.fn.getcwd()
expanded = root .. "/" .. expanded
end
return vim.fn.fnamemodify(expanded, ":p")
end
return M

View File

@@ -0,0 +1,308 @@
---@mod codetyper.agent Agent orchestration for Codetyper.nvim
---
--- Manages the agentic conversation loop with tool execution.
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")
---@class AgentState
---@field conversation table[] Message history for multi-turn
---@field pending_tool_results table[] Results waiting to be sent back
---@field is_running boolean Whether agent loop is active
---@field max_iterations number Maximum tool call iterations
local state = {
conversation = {},
pending_tool_results = {},
is_running = false,
max_iterations = 10,
current_iteration = 0,
}
---@class AgentCallbacks
---@field on_text fun(text: string) Called when text content is received
---@field on_tool_start fun(name: string) Called when a tool starts
---@field on_tool_result fun(name: string, result: string) Called when a tool completes
---@field on_complete fun() Called when agent finishes
---@field on_error fun(err: string) Called on error
--- Reset agent state for new conversation
function M.reset()
state.conversation = {}
state.pending_tool_results = {}
state.is_running = false
state.current_iteration = 0
end
--- Check if agent is currently running
---@return boolean
function M.is_running()
return state.is_running
end
--- Stop the agent
function M.stop()
state.is_running = false
utils.notify("Agent stopped")
end
--- Main agent entry point
---@param prompt string User's request
---@param context table File context
---@param callbacks AgentCallbacks Callback functions
function M.run(prompt, context, callbacks)
if state.is_running then
callbacks.on_error("Agent is already running")
return
end
logs.info("Starting agent run")
logs.debug("Prompt length: " .. #prompt .. " chars")
state.is_running = true
state.current_iteration = 0
-- Add user message to conversation
table.insert(state.conversation, {
role = "user",
content = prompt,
})
-- Start the agent loop
M.agent_loop(context, callbacks)
end
--- The core agent loop
---@param context table File context
---@param callbacks AgentCallbacks
function M.agent_loop(context, callbacks)
if not state.is_running then
callbacks.on_complete()
return
end
state.current_iteration = state.current_iteration + 1
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
return
end
local llm = require("codetyper.llm")
local client = llm.get_client()
-- Check if client supports tools
if not client.generate_with_tools then
logs.error("Provider does not support agent mode")
callbacks.on_error("Current LLM provider does not support agent mode")
state.is_running = false
return
end
logs.thinking("Calling LLM with " .. #state.conversation .. " messages...")
-- Generate with tools enabled
client.generate_with_tools(state.conversation, context, tools.definitions, function(response, err)
if err then
state.is_running = false
callbacks.on_error(err)
return
end
-- Parse response based on provider
local codetyper = require("codetyper")
local config = codetyper.get_config()
local parsed
if config.llm.provider == "claude" then
parsed = parser.parse_claude_response(response)
-- For Claude, preserve the original content array for proper tool_use handling
table.insert(state.conversation, {
role = "assistant",
content = response.content, -- Keep original content blocks for Claude API
})
else
-- For Ollama, response is the text directly
if type(response) == "string" then
parsed = parser.parse_ollama_response(response)
else
parsed = parser.parse_ollama_response(response.response or "")
end
-- Add assistant response to conversation
table.insert(state.conversation, {
role = "assistant",
content = parsed.text,
tool_calls = parsed.tool_calls,
})
end
-- Display any text content
if parsed.text and parsed.text ~= "" then
local clean_text = parser.clean_text(parsed.text)
if clean_text ~= "" then
callbacks.on_text(clean_text)
end
end
-- Check for tool calls
if #parsed.tool_calls > 0 then
logs.info(string.format("Processing %d tool call(s)", #parsed.tool_calls))
-- Process tool calls sequentially
M.process_tool_calls(parsed.tool_calls, 1, context, callbacks)
else
-- No more tool calls, agent is done
logs.info("No tool calls, finishing agent loop")
state.is_running = false
callbacks.on_complete()
end
end)
end
--- Process tool calls one at a time
---@param tool_calls table[] List of tool calls
---@param index number Current index
---@param context table File context
---@param callbacks AgentCallbacks
function M.process_tool_calls(tool_calls, index, context, callbacks)
if not state.is_running then
callbacks.on_complete()
return
end
if index > #tool_calls then
-- All tools processed, continue agent loop with results
M.continue_with_results(context, callbacks)
return
end
local tool_call = tool_calls[index]
callbacks.on_tool_start(tool_call.name)
executor.execute(tool_call.name, tool_call.parameters, function(result)
if result.requires_approval then
logs.tool(tool_call.name, "approval", "Waiting for user approval")
-- Show diff preview and wait for user decision
local show_fn
if result.diff_data.operation == "bash" then
show_fn = function(_, cb)
diff.show_bash_approval(result.diff_data.modified:gsub("^%$ ", ""), cb)
end
else
show_fn = diff.show_diff
end
show_fn(result.diff_data, function(approved)
if approved then
logs.tool(tool_call.name, "approved", "User approved")
-- Apply the change
executor.apply_change(result.diff_data, function(apply_result)
-- Store result for sending back to LLM
table.insert(state.pending_tool_results, {
tool_use_id = tool_call.id,
name = tool_call.name,
result = apply_result.result,
})
callbacks.on_tool_result(tool_call.name, apply_result.result)
-- Process next tool call
M.process_tool_calls(tool_calls, index + 1, context, callbacks)
end)
else
logs.tool(tool_call.name, "rejected", "User rejected")
-- User rejected
table.insert(state.pending_tool_results, {
tool_use_id = tool_call.id,
name = tool_call.name,
result = "User rejected this change",
})
callbacks.on_tool_result(tool_call.name, "Rejected by user")
M.process_tool_calls(tool_calls, index + 1, context, callbacks)
end
end)
else
-- No approval needed (read_file), store result immediately
table.insert(state.pending_tool_results, {
tool_use_id = tool_call.id,
name = tool_call.name,
result = result.result,
})
-- For read_file, just show a brief confirmation
local display_result = result.result
if tool_call.name == "read_file" and result.success then
display_result = "[Read " .. #result.result .. " bytes]"
end
callbacks.on_tool_result(tool_call.name, display_result)
M.process_tool_calls(tool_calls, index + 1, context, callbacks)
end
end)
end
--- Continue the loop after tool execution
---@param context table File context
---@param callbacks AgentCallbacks
function M.continue_with_results(context, callbacks)
if #state.pending_tool_results == 0 then
state.is_running = false
callbacks.on_complete()
return
end
-- Build tool results message
local codetyper = require("codetyper")
local config = codetyper.get_config()
if config.llm.provider == "claude" then
-- Claude format: tool_result blocks
local content = {}
for _, result in ipairs(state.pending_tool_results) do
table.insert(content, {
type = "tool_result",
tool_use_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"
for _, result in ipairs(state.pending_tool_results) do
result_text = result_text .. "\n[" .. result.name .. "]: " .. result.result .. "\n"
end
table.insert(state.conversation, {
role = "user",
content = result_text,
})
end
state.pending_tool_results = {}
-- Continue the loop
M.agent_loop(context, callbacks)
end
--- Get conversation history
---@return table[]
function M.get_conversation()
return state.conversation
end
--- Set max iterations
---@param max number Maximum iterations
function M.set_max_iterations(max)
state.max_iterations = max
end
return M

View File

@@ -0,0 +1,312 @@
---@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

@@ -0,0 +1,259 @@
---@mod codetyper.agent.logs Real-time logging for agent operations
---
--- Captures and displays the agent's thinking process, token usage, and LLM info.
local M = {}
---@class LogEntry
---@field timestamp string ISO timestamp
---@field level string "info" | "debug" | "request" | "response" | "tool" | "error"
---@field message string Log message
---@field data? table Optional structured data
---@class LogState
---@field entries LogEntry[] All log entries
---@field listeners table[] Functions to call when new entries are added
---@field total_prompt_tokens number Running total of prompt tokens
---@field total_response_tokens number Running total of response tokens
local state = {
entries = {},
listeners = {},
total_prompt_tokens = 0,
total_response_tokens = 0,
current_provider = nil,
current_model = nil,
}
--- Get current timestamp
---@return string
local function get_timestamp()
return os.date("%H:%M:%S")
end
--- Add a log entry
---@param level string Log level
---@param message string Log message
---@param data? table Optional data
function M.log(level, message, data)
local entry = {
timestamp = get_timestamp(),
level = level,
message = message,
data = data,
}
table.insert(state.entries, entry)
-- Notify all listeners
for _, listener in ipairs(state.listeners) do
pcall(listener, entry)
end
end
--- Log info message
---@param message string
---@param data? table
function M.info(message, data)
M.log("info", message, data)
end
--- Log debug message
---@param message string
---@param data? table
function M.debug(message, data)
M.log("debug", message, data)
end
--- Log API request
---@param provider string LLM provider
---@param model string Model name
---@param prompt_tokens? number Estimated prompt tokens
function M.request(provider, model, prompt_tokens)
state.current_provider = provider
state.current_model = model
local msg = string.format("[%s] %s", provider:upper(), model)
if prompt_tokens then
msg = msg .. string.format(" | Prompt: ~%d tokens", prompt_tokens)
end
M.log("request", msg, {
provider = provider,
model = model,
prompt_tokens = prompt_tokens,
})
end
--- Log API response with token usage
---@param prompt_tokens number Tokens used in prompt
---@param response_tokens number Tokens in response
---@param stop_reason? string Why the response stopped
function M.response(prompt_tokens, response_tokens, stop_reason)
state.total_prompt_tokens = state.total_prompt_tokens + prompt_tokens
state.total_response_tokens = state.total_response_tokens + response_tokens
local msg = string.format(
"Tokens: %d in / %d out | Total: %d in / %d out",
prompt_tokens,
response_tokens,
state.total_prompt_tokens,
state.total_response_tokens
)
if stop_reason then
msg = msg .. " | Stop: " .. stop_reason
end
M.log("response", msg, {
prompt_tokens = prompt_tokens,
response_tokens = response_tokens,
total_prompt = state.total_prompt_tokens,
total_response = state.total_response_tokens,
stop_reason = stop_reason,
})
end
--- Log tool execution
---@param tool_name string Name of the tool
---@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 msg = string.format("[%s] %s", icons[status] or status, tool_name)
if details then
msg = msg .. ": " .. details
end
M.log("tool", msg, {
tool = tool_name,
status = status,
details = details,
})
end
--- Log error
---@param message string
---@param data? table
function M.error(message, data)
M.log("error", "ERROR: " .. message, data)
end
--- Log warning
---@param message string
---@param data? table
function M.warning(message, data)
M.log("warning", "WARN: " .. message, data)
end
--- Add log entry (compatibility function for scheduler)
--- Accepts {type = "info", message = "..."} format
---@param entry table Log entry with type and message
function M.add(entry)
if entry.type == "clear" then
M.clear()
return
end
M.log(entry.type or "info", entry.message or "", entry.data)
end
--- Log thinking/reasoning step
---@param step string Description of what's happening
function M.thinking(step)
M.log("debug", "> " .. step)
end
--- Register a listener for new log entries
---@param callback fun(entry: LogEntry)
---@return number Listener ID for removal
function M.add_listener(callback)
table.insert(state.listeners, callback)
return #state.listeners
end
--- Remove a listener
---@param id number Listener ID
function M.remove_listener(id)
if id > 0 and id <= #state.listeners then
table.remove(state.listeners, id)
end
end
--- Get all log entries
---@return LogEntry[]
function M.get_entries()
return state.entries
end
--- Get token totals
---@return number, number prompt_tokens, response_tokens
function M.get_token_totals()
return state.total_prompt_tokens, state.total_response_tokens
end
--- Get current provider info
---@return string?, string? provider, model
function M.get_provider_info()
return state.current_provider, state.current_model
end
--- Clear all logs and reset counters
function M.clear()
state.entries = {}
state.total_prompt_tokens = 0
state.total_response_tokens = 0
state.current_provider = nil
state.current_model = nil
-- Notify listeners of clear
for _, listener in ipairs(state.listeners) do
pcall(listener, { level = "clear" })
end
end
--- Format entry for display
---@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 "?"
local base = string.format("[%s] %s %s", entry.timestamp, level_prefix, entry.message)
-- If this is a response entry with raw_response, append the full response
if entry.data and entry.data.raw_response then
local response = entry.data.raw_response
-- Add separator and the full response
base = base .. "\n" .. string.rep("-", 40) .. "\n" .. response .. "\n" .. string.rep("-", 40)
end
return base
end
--- Estimate token count for a string (rough approximation)
---@param text string
---@return number
function M.estimate_tokens(text)
-- Rough estimate: ~4 characters per token for English text
return math.ceil(#text / 4)
end
return M

View File

@@ -0,0 +1,117 @@
---@mod codetyper.agent.parser Response parser for agent tool calls
---
--- Parses LLM responses to extract tool calls from both Claude and Ollama.
local M = {}
---@class ParsedResponse
---@field text string Text content from the response
---@field tool_calls ToolCall[] List of tool calls
---@field stop_reason string Reason the response stopped
---@class ToolCall
---@field id string Unique identifier for the tool call
---@field name string Name of the tool to call
---@field parameters table Parameters for the tool
--- Parse Claude API response for tool_use blocks
---@param response table Raw Claude API response
---@return ParsedResponse
function M.parse_claude_response(response)
local result = {
text = "",
tool_calls = {},
stop_reason = response.stop_reason or "end_turn",
}
if response.content then
for _, block in ipairs(response.content) do
if block.type == "text" then
result.text = result.text .. (block.text or "")
elseif block.type == "tool_use" then
table.insert(result.tool_calls, {
id = block.id,
name = block.name,
parameters = block.input or {},
})
end
end
end
return result
end
--- Parse Ollama response for JSON tool blocks
---@param response_text string Raw text response from Ollama
---@return ParsedResponse
function M.parse_ollama_response(response_text)
local result = {
text = response_text,
tool_calls = {},
stop_reason = "end_turn",
}
-- Pattern to find JSON tool blocks in fenced code blocks
local fenced_pattern = "```json%s*(%b{})%s*```"
-- Find all fenced JSON blocks
for json_str in response_text:gmatch(fenced_pattern) do
local ok, parsed = pcall(vim.json.decode, json_str)
if ok and parsed.tool and parsed.parameters then
table.insert(result.tool_calls, {
id = string.format("%d_%d", os.time(), math.random(10000)),
name = parsed.tool,
parameters = parsed.parameters,
})
result.stop_reason = "tool_use"
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{}%})'
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
table.insert(result.tool_calls, {
id = string.format("%d_%d", os.time(), math.random(10000)),
name = parsed.tool,
parameters = parsed.parameters,
})
result.stop_reason = "tool_use"
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]")
end
return result
end
--- Check if response contains tool calls
---@param parsed ParsedResponse Parsed response
---@return boolean
function M.has_tool_calls(parsed)
return #parsed.tool_calls > 0
end
--- Extract just the text content, removing tool-related markup
---@param text string Response text
---@return string Cleaned text
function M.clean_text(text)
local cleaned = text
-- Remove tool JSON blocks
cleaned = cleaned:gsub("```json%s*%b{}%s*```", "")
cleaned = cleaned:gsub('%{"tool"%s*:%s*"[^"]+"%s*,%s*"parameters"%s*:%s*%b{}%}', "")
-- Clean up extra whitespace
cleaned = cleaned:gsub("\n\n\n+", "\n\n")
cleaned = cleaned:gsub("^%s+", ""):gsub("%s+$", "")
return cleaned
end
return M

View File

@@ -0,0 +1,665 @@
---@mod codetyper.agent.patch Patch system with staleness detection
---@brief [[
--- Manages code patches with buffer snapshots for staleness detection.
--- Patches are queued for safe injection when completion popup is not visible.
---@brief ]]
local M = {}
---@class BufferSnapshot
---@field bufnr number Buffer number
---@field changedtick number vim.b.changedtick at snapshot time
---@field content_hash string Hash of buffer content in range
---@field range {start_line: number, end_line: number}|nil Range snapshotted
---@class PatchCandidate
---@field id string Unique patch ID
---@field event_id string Related PromptEvent ID
---@field target_bufnr number Target buffer for injection
---@field target_path string Target file path
---@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 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
--- Patch storage
---@type PatchCandidate[]
local patches = {}
--- Patch ID counter
local patch_counter = 0
--- Generate unique patch ID
---@return string
function M.generate_id()
patch_counter = patch_counter + 1
return string.format("patch_%d_%d", os.time(), patch_counter)
end
--- Hash buffer content in range
---@param bufnr number
---@param start_line number|nil 1-indexed, nil for whole buffer
---@param end_line number|nil 1-indexed, nil for whole buffer
---@return string
local function hash_buffer_range(bufnr, start_line, end_line)
if not vim.api.nvim_buf_is_valid(bufnr) then
return ""
end
local lines
if start_line and end_line then
lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
else
lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
end
local content = table.concat(lines, "\n")
local hash = 0
for i = 1, #content do
hash = (hash * 31 + string.byte(content, i)) % 2147483647
end
return string.format("%x", hash)
end
--- Take a snapshot of buffer state
---@param bufnr number Buffer number
---@param range {start_line: number, end_line: number}|nil Optional range
---@return BufferSnapshot
function M.snapshot_buffer(bufnr, range)
local changedtick = 0
if vim.api.nvim_buf_is_valid(bufnr) then
changedtick = vim.api.nvim_buf_get_var(bufnr, "changedtick") or vim.b[bufnr].changedtick or 0
end
local content_hash
if range then
content_hash = hash_buffer_range(bufnr, range.start_line, range.end_line)
else
content_hash = hash_buffer_range(bufnr, nil, nil)
end
return {
bufnr = bufnr,
changedtick = changedtick,
content_hash = content_hash,
range = range,
}
end
--- Check if buffer changed since snapshot
---@param snapshot BufferSnapshot
---@return boolean is_stale
---@return string|nil reason
function M.is_snapshot_stale(snapshot)
if not vim.api.nvim_buf_is_valid(snapshot.bufnr) then
return true, "buffer_invalid"
end
-- Check changedtick first (fast path)
local current_tick = vim.api.nvim_buf_get_var(snapshot.bufnr, "changedtick")
or vim.b[snapshot.bufnr].changedtick or 0
if current_tick ~= snapshot.changedtick then
-- Changedtick differs, but might be just cursor movement
-- Verify with content hash
local current_hash
if snapshot.range then
current_hash = hash_buffer_range(
snapshot.bufnr,
snapshot.range.start_line,
snapshot.range.end_line
)
else
current_hash = hash_buffer_range(snapshot.bufnr, nil, nil)
end
if current_hash ~= snapshot.content_hash then
return true, "content_changed"
end
end
return false, nil
end
--- Check if a patch is stale
---@param patch PatchCandidate
---@return boolean
---@return string|nil reason
function M.is_stale(patch)
return M.is_snapshot_stale(patch.original_snapshot)
end
--- Queue a patch for deferred application
---@param patch PatchCandidate
---@return PatchCandidate
function M.queue_patch(patch)
patch.id = patch.id or M.generate_id()
patch.status = patch.status or "pending"
patch.created_at = patch.created_at or os.time()
table.insert(patches, patch)
-- Log patch creation
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "patch",
message = string.format(
"Patch queued: %s (confidence: %.2f)",
patch.id, patch.confidence or 0
),
data = {
patch_id = patch.id,
event_id = patch.event_id,
target_path = patch.target_path,
code_preview = patch.generated_code:sub(1, 50),
},
})
end)
return patch
end
--- Create patch from event and response
---@param event table PromptEvent
---@param generated_code string
---@param confidence number
---@param strategy string|nil Injection strategy (overrides intent-based)
---@return PatchCandidate
function M.create_from_event(event, generated_code, confidence, strategy)
-- Get target buffer
local target_bufnr = vim.fn.bufnr(event.target_path)
if target_bufnr == -1 then
-- Try to find by filename
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(buf)
if name == event.target_path then
target_bufnr = buf
break
end
end
end
-- 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(
target_bufnr ~= -1 and target_bufnr or event.bufnr,
snapshot_range
)
-- 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 intent_mod.is_replacement(event.intent) then
injection_strategy = "replace"
-- Use scope range for replacement
if event.scope_range then
injection_range = event.scope_range
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 }
elseif event.intent.action == "append" then
injection_strategy = "append"
-- Will append to end of file
else
injection_strategy = "append"
end
end
injection_strategy = injection_strategy or "append"
return {
id = M.generate_id(),
event_id = event.id,
target_bufnr = target_bufnr,
target_path = event.target_path,
original_snapshot = snapshot,
generated_code = generated_code,
injection_range = injection_range,
injection_strategy = injection_strategy,
confidence = confidence,
status = "pending",
created_at = os.time(),
intent = event.intent,
scope = event.scope,
-- Store the prompt tag range so we can delete it after applying
prompt_tag_range = event.range,
}
end
--- Get all pending patches
---@return PatchCandidate[]
function M.get_pending()
local pending = {}
for _, patch in ipairs(patches) do
if patch.status == "pending" then
table.insert(pending, patch)
end
end
return pending
end
--- Get patch by ID
---@param id string
---@return PatchCandidate|nil
function M.get(id)
for _, patch in ipairs(patches) do
if patch.id == id then
return patch
end
end
return nil
end
--- Get patches for event
---@param event_id string
---@return PatchCandidate[]
function M.get_for_event(event_id)
local result = {}
for _, patch in ipairs(patches) do
if patch.event_id == event_id then
table.insert(result, patch)
end
end
return result
end
--- Mark patch as applied
---@param id string
---@return boolean
function M.mark_applied(id)
local patch = M.get(id)
if patch then
patch.status = "applied"
patch.applied_at = os.time()
return true
end
return false
end
--- Mark patch as stale
---@param id string
---@param reason string|nil
---@return boolean
function M.mark_stale(id, reason)
local patch = M.get(id)
if patch then
patch.status = "stale"
patch.stale_reason = reason
return true
end
return false
end
--- Mark patch as rejected
---@param id string
---@param reason string|nil
---@return boolean
function M.mark_rejected(id, reason)
local patch = M.get(id)
if patch then
patch.status = "rejected"
patch.reject_reason = reason
return true
end
return false
end
--- Remove /@ @/ prompt tags from buffer
---@param bufnr number Buffer number
---@return number Number of tag regions removed
local function remove_prompt_tags(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) then
return 0
end
local removed = 0
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
-- Find and remove all /@ ... @/ regions (can be multiline)
local i = 1
while i <= #lines do
local line = lines[i]
local open_start = line:find("/@")
if open_start then
-- Found an opening tag, look for closing tag
local close_end = nil
local close_line = i
-- Check if closing tag is on same line
local after_open = line:sub(open_start + 2)
local same_line_close = after_open:find("@/")
if same_line_close then
-- Single line tag - remove just this portion
local before = line:sub(1, open_start - 1)
local after = line:sub(open_start + 2 + same_line_close + 1)
lines[i] = before .. after
-- If line is now empty or just whitespace, remove it
if lines[i]:match("^%s*$") then
table.remove(lines, i)
else
i = i + 1
end
removed = removed + 1
else
-- Multi-line tag - find the closing line
for j = i, #lines do
if lines[j]:find("@/") then
close_line = j
close_end = lines[j]:find("@/")
break
end
end
if close_end then
-- Remove lines from i to close_line
-- Keep content before /@ on first line and after @/ on last line
local before = lines[i]:sub(1, open_start - 1)
local after = lines[close_line]:sub(close_end + 2)
-- Remove the lines containing the tag
for _ = i, close_line do
table.remove(lines, i)
end
-- If there's content to keep, insert it back
local remaining = (before .. after):match("^%s*(.-)%s*$")
if remaining and remaining ~= "" then
table.insert(lines, i, remaining)
i = i + 1
end
removed = removed + 1
else
-- No closing tag found, skip this line
i = i + 1
end
end
else
i = i + 1
end
end
if removed > 0 then
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
end
return removed
end
--- Check if it's safe to modify the buffer (not in insert mode)
---@return boolean
local function is_safe_to_modify()
local mode = vim.fn.mode()
-- Don't modify if in insert mode or completion is visible
if mode == "i" or mode == "ic" or mode == "ix" then
return false
end
if vim.fn.pumvisible() == 1 then
return false
end
return true
end
--- Apply a patch to the target buffer
---@param patch PatchCandidate
---@return boolean success
---@return string|nil error
function M.apply(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)
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "warning",
message = string.format("Patch %s is stale: %s", patch.id, stale_reason or "unknown"),
})
end)
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
-- Try to load buffer from path
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
-- Prepare code lines
local code_lines = vim.split(patch.generated_code, "\n", { plain = true })
-- FIRST: Remove the prompt tags from the buffer before applying code
-- This prevents the infinite loop where tags stay and get re-detected
local tags_removed = remove_prompt_tags(target_bufnr)
pcall(function()
if tags_removed > 0 then
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = string.format("Removed %d prompt tag(s) from buffer", tags_removed),
})
end
end)
-- Recalculate line count after tag removal
local line_count = vim.api.nvim_buf_line_count(target_bufnr)
-- Apply based on strategy
local ok, err = pcall(function()
if patch.injection_strategy == "replace" and patch.injection_range then
-- Replace the scope range with the new code
-- The injection_range points to the function/method we're completing
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
-- Use the scope information to find the correct range
if patch.scope and patch.scope.type then
-- Try to find the scope using treesitter if available
local found_range = nil
pcall(function()
local ts_utils = require("nvim-treesitter.ts_utils")
local parsers = require("nvim-treesitter.parsers")
local parser = parsers.get_parser(target_bufnr)
if parser then
local tree = parser:parse()[1]
if tree then
local root = tree:root()
-- Find the function/method node that contains our original position
local function find_scope_node(node)
local node_type = node:type()
local is_scope = node_type:match("function")
or node_type:match("method")
or node_type:match("class")
or node_type:match("declaration")
if is_scope then
local s_row, _, e_row, _ = node:range()
-- Check if this scope roughly matches our expected range
if math.abs(s_row - (start_line - 1)) <= 5 then
found_range = { start_line = s_row + 1, end_line = e_row + 1 }
return true
end
end
for child in node:iter_children() do
if find_scope_node(child) then
return true
end
end
return false
end
find_scope_node(root)
end
end
end)
if found_range then
start_line = found_range.start_line
end_line = found_range.end_line
end
end
-- Clamp to valid range
start_line = math.max(1, start_line)
end_line = math.min(line_count, end_line)
-- Replace the range (0-indexed for nvim_buf_set_lines)
vim.api.nvim_buf_set_lines(target_bufnr, start_line - 1, end_line, false, code_lines)
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = string.format("Replacing lines %d-%d with %d lines of code", start_line, end_line, #code_lines),
})
end)
elseif patch.injection_strategy == "insert" and patch.injection_range then
-- Insert at the specified location
local insert_line = patch.injection_range.start_line
insert_line = math.max(1, math.min(line_count + 1, insert_line))
vim.api.nvim_buf_set_lines(target_bufnr, insert_line - 1, insert_line - 1, false, code_lines)
else
-- Default: append to end
-- Check if last line is empty, if not add a blank line for spacing
local last_line = vim.api.nvim_buf_get_lines(target_bufnr, line_count - 1, line_count, false)[1] or ""
if last_line:match("%S") then
-- Last line has content, add blank line for spacing
table.insert(code_lines, 1, "")
end
vim.api.nvim_buf_set_lines(target_bufnr, line_count, line_count, false, code_lines)
end
end)
if not ok then
M.mark_rejected(patch.id, err)
return false, err
end
M.mark_applied(patch.id)
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "success",
message = string.format("Patch %s applied successfully", patch.id),
data = {
target_path = patch.target_path,
lines_added = #code_lines,
},
})
end)
return true, nil
end
--- Flush all pending patches that are safe to apply
---@return number applied_count
---@return number stale_count
---@return number deferred_count
function M.flush_pending()
local applied = 0
local stale = 0
local deferred = 0
for _, p in ipairs(patches) do
if p.status == "pending" then
local success, err = M.apply(p)
if success then
applied = applied + 1
elseif err == "user_typing" then
-- Keep pending, will retry later
deferred = deferred + 1
else
stale = stale + 1
end
end
end
return applied, stale, deferred
end
--- Cancel all pending patches for a buffer
---@param bufnr number
---@return number cancelled_count
function M.cancel_for_buffer(bufnr)
local cancelled = 0
for _, patch in ipairs(patches) do
if patch.status == "pending" and
(patch.target_bufnr == bufnr or patch.original_snapshot.bufnr == bufnr) then
patch.status = "cancelled"
cancelled = cancelled + 1
end
end
return cancelled
end
--- Cleanup old patches
---@param max_age number Max age in seconds (default: 300)
function M.cleanup(max_age)
max_age = max_age or 300
local now = os.time()
local i = 1
while i <= #patches do
local patch = patches[i]
if patch.status ~= "pending" and (now - patch.created_at) > max_age then
table.remove(patches, i)
else
i = i + 1
end
end
end
--- Get statistics
---@return table
function M.stats()
local stats = {
total = #patches,
pending = 0,
applied = 0,
stale = 0,
rejected = 0,
cancelled = 0,
}
for _, patch in ipairs(patches) do
local s = patch.status
if stats[s] then
stats[s] = stats[s] + 1
end
end
return stats
end
--- Clear all patches
function M.clear()
patches = {}
end
return M

View File

@@ -0,0 +1,451 @@
---@mod codetyper.agent.queue Event queue for prompt processing
---@brief [[
--- Priority queue system for PromptEvents with observer pattern.
--- Events are processed by priority (1=high, 2=normal, 3=low).
---@brief ]]
local M = {}
---@class AttachedFile
---@field path string Relative path as referenced in prompt
---@field full_path string Absolute path to the file
---@field content string File content
---@class PromptEvent
---@field id string Unique event ID
---@field bufnr number Source buffer number
---@field range {start_line: number, end_line: number} Line range of prompt tag
---@field timestamp number os.clock() timestamp
---@field changedtick number Buffer changedtick snapshot
---@field content_hash string Hash of prompt region
---@field prompt_content string Cleaned prompt text
---@field target_path string Target file for injection
---@field priority number Priority (1=high, 2=normal, 3=low)
---@field status string "pending"|"processing"|"completed"|"escalated"|"cancelled"|"needs_context"|"failed"
---@field attempt_count number Number of processing attempts
---@field worker_type string|nil LLM provider used ("ollama"|"claude"|etc)
---@field created_at number System time when created
---@field intent Intent|nil Detected intent from prompt
---@field scope ScopeInfo|nil Resolved scope (function/class/file)
---@field scope_text string|nil Text of the resolved scope
---@field scope_range {start_line: number, end_line: number}|nil Range of scope in target
---@field attached_files AttachedFile[]|nil Files attached via @filename syntax
--- Internal state
---@type PromptEvent[]
local queue = {}
--- Event listeners (observer pattern)
---@type function[]
local listeners = {}
--- Event ID counter
local event_counter = 0
--- Generate unique event ID
---@return string
function M.generate_id()
event_counter = event_counter + 1
return string.format("evt_%d_%d", os.time(), event_counter)
end
--- Simple hash function for content
---@param content string
---@return string
function M.hash_content(content)
local hash = 0
for i = 1, #content do
hash = (hash * 31 + string.byte(content, i)) % 2147483647
end
return string.format("%x", hash)
end
--- Notify all listeners of queue change
---@param event_type string "enqueue"|"dequeue"|"update"|"cancel"
---@param event PromptEvent|nil The affected event
local function notify_listeners(event_type, event)
for _, listener in ipairs(listeners) do
pcall(listener, event_type, event, #queue)
end
end
--- Add event listener
---@param callback function(event_type: string, event: PromptEvent|nil, queue_size: number)
---@return number Listener ID for removal
function M.add_listener(callback)
table.insert(listeners, callback)
return #listeners
end
--- Remove event listener
---@param listener_id number
function M.remove_listener(listener_id)
if listener_id > 0 and listener_id <= #listeners then
table.remove(listeners, listener_id)
end
end
--- Compare events for priority sorting
---@param a PromptEvent
---@param b PromptEvent
---@return boolean
local function compare_priority(a, b)
-- Lower priority number = higher priority
if a.priority ~= b.priority then
return a.priority < b.priority
end
-- Same priority: older events first (FIFO)
return a.timestamp < b.timestamp
end
--- Check if two events are in the same scope
---@param a PromptEvent
---@param b PromptEvent
---@return boolean
local function same_scope(a, b)
-- Same buffer
if a.target_path ~= b.target_path then
return false
end
-- Both have scope ranges
if a.scope_range and b.scope_range then
-- Check if ranges overlap
return a.scope_range.start_line <= b.scope_range.end_line
and b.scope_range.start_line <= a.scope_range.end_line
end
-- Fallback: check if prompt ranges are close (within 10 lines)
if a.range and b.range then
local distance = math.abs(a.range.start_line - b.range.start_line)
return distance < 10
end
return false
end
--- Find conflicting events in the same scope
---@param event PromptEvent
---@return PromptEvent[] Conflicting pending events
function M.find_conflicts(event)
local conflicts = {}
for _, existing in ipairs(queue) do
if existing.status == "pending" and existing.id ~= event.id then
if same_scope(event, existing) then
table.insert(conflicts, existing)
end
end
end
return conflicts
end
--- Check if an event should be skipped due to conflicts (first tag wins)
---@param event PromptEvent
---@return boolean should_skip
---@return string|nil reason
function M.check_precedence(event)
local conflicts = M.find_conflicts(event)
for _, conflict in ipairs(conflicts) do
-- First (older) tag wins
if conflict.timestamp < event.timestamp then
return true, string.format(
"Skipped: earlier tag in same scope (event %s)",
conflict.id
)
end
end
return false, nil
end
--- Insert event maintaining priority order
---@param event PromptEvent
local function insert_sorted(event)
local pos = #queue + 1
for i, existing in ipairs(queue) do
if compare_priority(event, existing) then
pos = i
break
end
end
table.insert(queue, pos, event)
end
--- Enqueue a new event
---@param event PromptEvent
---@return PromptEvent The enqueued event with generated ID if missing
function M.enqueue(event)
-- Ensure required fields
event.id = event.id or M.generate_id()
event.timestamp = event.timestamp or os.clock()
event.created_at = event.created_at or os.time()
event.status = event.status or "pending"
event.priority = event.priority or 2
event.attempt_count = event.attempt_count or 0
-- Generate content hash if not provided
if not event.content_hash and event.prompt_content then
event.content_hash = M.hash_content(event.prompt_content)
end
insert_sorted(event)
notify_listeners("enqueue", event)
-- Log to agent logs if available
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "queue",
message = string.format("Event queued: %s (priority: %d)", event.id, event.priority),
data = {
event_id = event.id,
bufnr = event.bufnr,
prompt_preview = event.prompt_content:sub(1, 50),
},
})
end)
return event
end
--- Dequeue highest priority pending event
---@return PromptEvent|nil
function M.dequeue()
for i, event in ipairs(queue) do
if event.status == "pending" then
event.status = "processing"
notify_listeners("dequeue", event)
return event
end
end
return nil
end
--- Peek at next pending event without removing
---@return PromptEvent|nil
function M.peek()
for _, event in ipairs(queue) do
if event.status == "pending" then
return event
end
end
return nil
end
--- Get event by ID
---@param id string
---@return PromptEvent|nil
function M.get(id)
for _, event in ipairs(queue) do
if event.id == id then
return event
end
end
return nil
end
--- Update event status
---@param id string
---@param status string
---@param extra table|nil Additional fields to update
---@return boolean Success
function M.update_status(id, status, extra)
for _, event in ipairs(queue) do
if event.id == id then
event.status = status
if extra then
for k, v in pairs(extra) do
event[k] = v
end
end
notify_listeners("update", event)
return true
end
end
return false
end
--- Mark event as completed
---@param id string
---@return boolean
function M.complete(id)
return M.update_status(id, "completed")
end
--- Mark event as escalated (needs remote LLM)
---@param id string
---@return boolean
function M.escalate(id)
local event = M.get(id)
if event then
event.status = "escalated"
event.attempt_count = event.attempt_count + 1
-- Re-queue as pending with same priority
event.status = "pending"
notify_listeners("update", event)
return true
end
return false
end
--- Cancel all events for a buffer
---@param bufnr number
---@return number Number of cancelled events
function M.cancel_for_buffer(bufnr)
local cancelled = 0
for _, event in ipairs(queue) do
if event.bufnr == bufnr and event.status == "pending" then
event.status = "cancelled"
cancelled = cancelled + 1
notify_listeners("cancel", event)
end
end
return cancelled
end
--- Cancel event by ID
---@param id string
---@return boolean
function M.cancel(id)
return M.update_status(id, "cancelled")
end
--- Get all pending events
---@return PromptEvent[]
function M.get_pending()
local pending = {}
for _, event in ipairs(queue) do
if event.status == "pending" then
table.insert(pending, event)
end
end
return pending
end
--- Get all processing events
---@return PromptEvent[]
function M.get_processing()
local processing = {}
for _, event in ipairs(queue) do
if event.status == "processing" then
table.insert(processing, event)
end
end
return processing
end
--- Get queue size (all events)
---@return number
function M.size()
return #queue
end
--- Get count of pending events
---@return number
function M.pending_count()
local count = 0
for _, event in ipairs(queue) do
if event.status == "pending" then
count = count + 1
end
end
return count
end
--- Get count of processing events
---@return number
function M.processing_count()
local count = 0
for _, event in ipairs(queue) do
if event.status == "processing" then
count = count + 1
end
end
return count
end
--- Check if queue is empty (no pending events)
---@return boolean
function M.is_empty()
return M.pending_count() == 0
end
--- Clear all events (optionally filter by status)
---@param status string|nil Status to clear, or nil for all
function M.clear(status)
if status then
local i = 1
while i <= #queue do
if queue[i].status == status then
table.remove(queue, i)
else
i = i + 1
end
end
else
queue = {}
end
notify_listeners("update", nil)
end
--- Cleanup completed/cancelled/failed events older than max_age seconds
---@param max_age number Maximum age in seconds (default: 300)
function M.cleanup(max_age)
max_age = max_age or 300
local now = os.time()
local terminal_statuses = {
completed = true,
cancelled = true,
failed = true,
needs_context = true,
}
local i = 1
while i <= #queue do
local event = queue[i]
if terminal_statuses[event.status] and (now - event.created_at) > max_age then
table.remove(queue, i)
else
i = i + 1
end
end
end
--- Get queue statistics
---@return table
function M.stats()
local stats = {
total = #queue,
pending = 0,
processing = 0,
completed = 0,
cancelled = 0,
escalated = 0,
failed = 0,
needs_context = 0,
}
for _, event in ipairs(queue) do
local s = event.status
if stats[s] then
stats[s] = stats[s] + 1
end
end
return stats
end
--- Debug: dump queue contents
---@return string
function M.dump()
local lines = { "Queue contents:" }
for i, event in ipairs(queue) do
table.insert(lines, string.format(
" %d. [%s] %s (p:%d, status:%s, attempts:%d)",
i, event.id,
event.prompt_content:sub(1, 30):gsub("\n", " "),
event.priority, event.status, event.attempt_count
))
end
return table.concat(lines, "\n")
end
return M

View File

@@ -0,0 +1,579 @@
---@mod codetyper.agent.scheduler Event scheduler with completion-awareness
---@brief [[
--- Central orchestrator for the event-driven system.
--- Handles dispatch, escalation, and completion-safe injection.
---@brief ]]
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")
-- Setup context modal cleanup on exit
context_modal.setup()
--- Scheduler state
local state = {
running = false,
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 = "claude", -- Default fallback provider
},
}
--- Autocommand group for injection timing
local augroup = nil
--- Check if completion popup is visible
---@return boolean
function M.is_completion_visible()
-- Check native popup menu
if vim.fn.pumvisible() == 1 then
return true
end
-- Check nvim-cmp
local ok, cmp = pcall(require, "cmp")
if ok and cmp.visible and cmp.visible() then
return true
end
-- Check coq_nvim
local coq_ok, coq = pcall(require, "coq")
if coq_ok and coq and type(coq.visible) == "function" and coq.visible() then
return true
end
return false
end
--- Check if we're in insert mode
---@return boolean
function M.is_insert_mode()
local mode = vim.fn.mode()
return mode == "i" or mode == "ic" or mode == "ix"
end
--- Check if it's safe to inject code
---@return boolean
---@return string|nil reason if not safe
function M.is_safe_to_inject()
if M.is_completion_visible() then
return false, "completion_visible"
end
if M.is_insert_mode() then
return false, "insert_mode"
end
return true, nil
end
--- Get the provider for escalation
---@return string
local function get_remote_provider()
local ok, codetyper = pcall(require, "codetyper")
if ok then
local config = codetyper.get_config()
if config and config.llm and config.llm.provider then
-- If current provider is ollama, use configured remote
if config.llm.provider == "ollama" then
-- Check which remote provider is configured
if config.llm.claude and config.llm.claude.api_key then
return "claude"
elseif config.llm.openai and config.llm.openai.api_key then
return "openai"
elseif config.llm.gemini and config.llm.gemini.api_key then
return "gemini"
elseif config.llm.copilot then
return "copilot"
end
end
return config.llm.provider
end
end
return state.config.remote_provider
end
--- Get the primary provider (ollama if scout enabled, else configured)
---@return string
local function get_primary_provider()
if state.config.ollama_scout then
return "ollama"
end
local ok, codetyper = pcall(require, "codetyper")
if ok then
local config = codetyper.get_config()
if config and config.llm and config.llm.provider then
return config.llm.provider
end
end
return "claude"
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)
-- Create new prompt content combining original + additional
local combined_prompt = string.format(
"%s\n\nAdditional context:\n%s",
original_event.prompt_content,
additional_context
)
-- Create a new event with the combined prompt
local new_event = vim.deepcopy(original_event)
new_event.id = nil -- Will be assigned a new ID
new_event.prompt_content = combined_prompt
new_event.attempt_count = 0
new_event.status = nil
-- Log the retry
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = string.format("Retrying with additional context (original: %s)", original_event.id),
})
end)
-- Queue the new event
queue.enqueue(new_event)
end
--- Process worker result and decide next action
---@param event table PromptEvent
---@param result table WorkerResult
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")
logs.add({
type = "info",
message = string.format("Event %s: LLM needs more context, opening modal", event.id),
})
end)
-- Open the context modal
context_modal.open(result.original_event or event, result.response or "", retry_with_context)
-- Mark original event as needing context (not failed)
queue.update_status(event.id, "needs_context", { response = result.response })
return
end
if not result.success then
-- 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")
logs.add({
type = "info",
message = string.format(
"Escalating event %s to remote provider (ollama failed)",
event.id
),
})
end)
event.attempt_count = event.attempt_count + 1
event.status = "pending"
event.worker_type = get_remote_provider()
return
end
-- Mark as failed
queue.update_status(event.id, "failed", { error = result.error })
return
end
-- Success - check confidence
local needs_escalation = confidence_mod.needs_escalation(
result.confidence,
state.config.escalation_threshold
)
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")
logs.add({
type = "info",
message = string.format(
"Escalating event %s to remote provider (confidence: %.2f < %.2f)",
event.id, result.confidence, state.config.escalation_threshold
),
})
end)
event.attempt_count = event.attempt_count + 1
event.status = "pending"
event.worker_type = get_remote_provider()
return
end
-- Good enough or final attempt - create patch
local p = patch.create_from_event(event, result.response, result.confidence)
patch.queue_patch(p)
queue.complete(event.id)
-- 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")
logs.add({
type = "info",
message = string.format("Code ready. Applying in %.1f seconds...", delay / 1000),
})
end)
vim.defer_fn(function()
M.schedule_patch_flush()
end, delay)
end
--- Dispatch next event from queue
local function dispatch_next()
if state.paused then
return
end
-- Check concurrent limit
if worker.active_count() >= state.config.max_concurrent then
return
end
-- Get next pending event
local event = queue.dequeue()
if not event then
return
end
-- Check for precedence conflicts (multiple tags in same scope)
local should_skip, skip_reason = queue.check_precedence(event)
if should_skip then
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "warning",
message = string.format("Event %s skipped: %s", event.id, skip_reason or "conflict"),
})
end)
queue.cancel(event.id)
-- Try next event
return dispatch_next()
end
-- Determine which provider to use
local provider = event.worker_type or get_primary_provider()
-- Log dispatch with intent/scope info
pcall(function()
local logs = require("codetyper.agent.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")
or "file"
logs.add({
type = "info",
message = string.format(
"Dispatching %s [intent: %s, scope: %s, provider: %s]",
event.id, intent_info, scope_info, provider
),
})
end)
-- Create worker
worker.create(event, provider, function(result)
vim.schedule(function()
handle_worker_result(event, result)
end)
end)
end
--- Track if we're already waiting to flush (avoid spam logs)
local waiting_to_flush = false
--- Schedule patch flush after delay (completion safety)
--- Will keep retrying until safe to inject or no pending patches
function M.schedule_patch_flush()
vim.defer_fn(function()
-- Check if there are any pending patches
local pending = patch.get_pending()
if #pending == 0 then
waiting_to_flush = false
return -- Nothing to apply
end
local safe, reason = M.is_safe_to_inject()
if safe then
waiting_to_flush = false
local applied, stale = patch.flush_pending()
if applied > 0 or stale > 0 then
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = string.format("Patches flushed: %d applied, %d stale", applied, stale),
})
end)
end
else
-- Not safe yet (user is typing), reschedule to try again
-- Only log once when we start waiting
if not waiting_to_flush then
waiting_to_flush = true
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = "Waiting for user to finish typing before applying code...",
})
end)
end
-- Retry after a delay - keep waiting for user to finish typing
M.schedule_patch_flush()
end
end, state.config.completion_delay_ms)
end
--- Main scheduler loop
local function scheduler_loop()
if not state.running then
return
end
dispatch_next()
-- Cleanup old items periodically
if math.random() < 0.01 then -- ~1% chance each tick
queue.cleanup(300)
patch.cleanup(300)
end
-- Schedule next tick
state.timer = vim.defer_fn(scheduler_loop, state.poll_interval)
end
--- Setup autocommands for injection timing
local function setup_autocmds()
if augroup then
pcall(vim.api.nvim_del_augroup_by_id, augroup)
end
augroup = vim.api.nvim_create_augroup("CodetypeScheduler", { clear = true })
-- Flush patches when leaving insert mode
vim.api.nvim_create_autocmd("InsertLeave", {
group = augroup,
callback = function()
vim.defer_fn(function()
if not M.is_completion_visible() then
patch.flush_pending()
end
end, state.config.completion_delay_ms)
end,
desc = "Flush pending patches on InsertLeave",
})
-- Flush patches on cursor hold
vim.api.nvim_create_autocmd("CursorHold", {
group = augroup,
callback = function()
if not M.is_insert_mode() and not M.is_completion_visible() then
patch.flush_pending()
end
end,
desc = "Flush pending patches on CursorHold",
})
-- Cancel patches when buffer changes significantly
vim.api.nvim_create_autocmd("BufWritePre", {
group = augroup,
callback = function(ev)
-- Mark relevant patches as potentially stale
-- They'll be checked on next flush attempt
end,
desc = "Check patch staleness on save",
})
-- Cleanup when buffer is deleted
vim.api.nvim_create_autocmd("BufDelete", {
group = augroup,
callback = function(ev)
queue.cancel_for_buffer(ev.buf)
patch.cancel_for_buffer(ev.buf)
worker.cancel_for_event(ev.buf)
end,
desc = "Cleanup on buffer delete",
})
-- Stop scheduler when exiting Neovim
vim.api.nvim_create_autocmd("VimLeavePre", {
group = augroup,
callback = function()
M.stop()
end,
desc = "Stop scheduler before exiting Neovim",
})
end
--- Start the scheduler
---@param config table|nil Configuration overrides
function M.start(config)
if state.running then
return
end
-- Merge config
if config then
for k, v in pairs(config) do
state.config[k] = v
end
end
-- Load config from codetyper if available
pcall(function()
local codetyper = require("codetyper")
local ct_config = codetyper.get_config()
if ct_config and ct_config.scheduler then
for k, v in pairs(ct_config.scheduler) do
state.config[k] = v
end
end
end)
if not state.config.enabled then
return
end
state.running = true
state.paused = false
-- Setup autocmds
setup_autocmds()
-- Add queue listener
queue.add_listener(function(event_type, event, queue_size)
if event_type == "enqueue" and not state.paused then
-- New event - try to dispatch immediately
vim.schedule(dispatch_next)
end
end)
-- Start main loop
scheduler_loop()
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = "Scheduler started",
data = {
ollama_scout = state.config.ollama_scout,
escalation_threshold = state.config.escalation_threshold,
max_concurrent = state.config.max_concurrent,
},
})
end)
end
--- Stop the scheduler
function M.stop()
state.running = false
if state.timer then
pcall(function()
if type(state.timer) == "userdata" and state.timer.stop then
state.timer:stop()
end
end)
state.timer = nil
end
if augroup then
pcall(vim.api.nvim_del_augroup_by_id, augroup)
augroup = nil
end
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = "Scheduler stopped",
})
end)
end
--- Pause the scheduler (don't process new events)
function M.pause()
state.paused = true
end
--- Resume the scheduler
function M.resume()
state.paused = false
vim.schedule(dispatch_next)
end
--- Check if scheduler is running
---@return boolean
function M.is_running()
return state.running
end
--- Check if scheduler is paused
---@return boolean
function M.is_paused()
return state.paused
end
--- Get scheduler status
---@return table
function M.status()
return {
running = state.running,
paused = state.paused,
queue_stats = queue.stats(),
patch_stats = patch.stats(),
active_workers = worker.active_count(),
config = vim.deepcopy(state.config),
}
end
--- Manually trigger dispatch
function M.dispatch()
if state.running and not state.paused then
dispatch_next()
end
end
--- Force flush all pending patches (ignores completion check)
function M.force_flush()
return patch.flush_pending()
end
--- Update configuration
---@param config table
function M.configure(config)
for k, v in pairs(config) do
state.config[k] = v
end
end
return M

View File

@@ -0,0 +1,470 @@
---@mod codetyper.agent.scope Tree-sitter scope resolution
---@brief [[
--- Resolves semantic scope for prompts using Tree-sitter.
--- Finds the smallest enclosing function/method/block for a given position.
---@brief ]]
local M = {}
---@class ScopeInfo
---@field type string "function"|"method"|"class"|"block"|"file"|"unknown"
---@field node_type string Tree-sitter node type
---@field range {start_row: number, start_col: number, end_row: number, end_col: number}
---@field text string The full text of the scope
---@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",
}
--- Check if Tree-sitter is available for buffer
---@param bufnr number
---@return boolean
function M.has_treesitter(bufnr)
-- Try to get the language for this buffer
local lang = nil
-- Method 1: Use vim.treesitter (Neovim 0.9+)
if vim.treesitter and vim.treesitter.language then
local ft = vim.bo[bufnr].filetype
if vim.treesitter.language.get_lang then
lang = vim.treesitter.language.get_lang(ft)
else
lang = ft
end
end
-- Method 2: Try nvim-treesitter parsers module
if not lang then
local ok, parsers = pcall(require, "nvim-treesitter.parsers")
if ok and parsers then
if parsers.get_buf_lang then
lang = parsers.get_buf_lang(bufnr)
elseif parsers.ft_to_lang then
lang = parsers.ft_to_lang(vim.bo[bufnr].filetype)
end
end
end
-- Fallback to filetype
if not lang then
lang = vim.bo[bufnr].filetype
end
if not lang or lang == "" then
return false
end
-- Check if parser is available
local has_parser = pcall(vim.treesitter.get_parser, bufnr, lang)
return has_parser
end
--- Get Tree-sitter node at position
---@param bufnr number
---@param row number 0-indexed
---@param col number 0-indexed
---@return TSNode|nil
local function get_node_at_pos(bufnr, row, col)
local ok, ts_utils = pcall(require, "nvim-treesitter.ts_utils")
if not ok then
return nil
end
-- Try to get the node at the cursor position
local node = ts_utils.get_node_at_cursor()
if node then
return node
end
-- Fallback: get root and find node
local parser = vim.treesitter.get_parser(bufnr)
if not parser then
return nil
end
local tree = parser:parse()[1]
if not tree then
return nil
end
local root = tree:root()
return root:named_descendant_for_range(row, col, row, col)
end
--- Find enclosing scope node of specific types
---@param node TSNode
---@param node_types table<string, string>
---@return TSNode|nil, string|nil scope_type
local function find_enclosing_scope(node, node_types)
local current = node
while current do
local node_type = current:type()
if node_types[node_type] then
return current, node_types[node_type]
end
current = current:parent()
end
return nil, nil
end
--- Extract function/method name from node
---@param node TSNode
---@param bufnr number
---@return string|nil
local function get_scope_name(node, bufnr)
-- Try to find name child node
local name_node = node:field("name")[1]
if name_node then
return vim.treesitter.get_node_text(name_node, bufnr)
end
-- Try identifier child
for child in node:iter_children() do
if child:type() == "identifier" or child:type() == "property_identifier" then
return vim.treesitter.get_node_text(child, bufnr)
end
end
return nil
end
--- Resolve scope at position using Tree-sitter
---@param bufnr number Buffer number
---@param row number 1-indexed line number
---@param col number 1-indexed column number
---@return ScopeInfo
function M.resolve_scope(bufnr, row, col)
-- Default to file scope
local default_scope = {
type = "file",
node_type = "file",
range = {
start_row = 1,
start_col = 0,
end_row = vim.api.nvim_buf_line_count(bufnr),
end_col = 0,
},
text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n"),
name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":t"),
}
-- Check if Tree-sitter is available
if not M.has_treesitter(bufnr) then
-- Fall back to heuristic-based scope resolution
return M.resolve_scope_heuristic(bufnr, row, col) or default_scope
end
-- Convert to 0-indexed for Tree-sitter
local ts_row = row - 1
local ts_col = col - 1
-- Get node at position
local node = get_node_at_pos(bufnr, ts_row, ts_col)
if not node then
return default_scope
end
-- Try to find function scope first
local scope_node, scope_type = find_enclosing_scope(node, function_nodes)
-- If no function, try class
if not scope_node then
scope_node, scope_type = find_enclosing_scope(node, class_nodes)
end
-- If no class, try block
if not scope_node then
scope_node, scope_type = find_enclosing_scope(node, block_nodes)
end
if not scope_node then
return default_scope
end
-- Get range (convert back to 1-indexed)
local start_row, start_col, end_row, end_col = scope_node:range()
-- Get text
local text = vim.treesitter.get_node_text(scope_node, bufnr)
-- Get name
local name = get_scope_name(scope_node, bufnr)
return {
type = scope_type,
node_type = scope_node:type(),
range = {
start_row = start_row + 1,
start_col = start_col,
end_row = end_row + 1,
end_col = end_col,
},
text = text,
name = name,
}
end
--- Heuristic fallback for scope resolution (no Tree-sitter)
---@param bufnr number
---@param row number 1-indexed
---@param col number 1-indexed
---@return ScopeInfo|nil
function M.resolve_scope_heuristic(bufnr, row, col)
_ = col -- unused in heuristic
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local filetype = vim.bo[bufnr].filetype
-- Language-specific function patterns
local patterns = {
lua = {
start = "^%s*local%s+function%s+",
start_alt = "^%s*function%s+",
ending = "^%s*end%s*$",
},
python = {
start = "^%s*def%s+",
start_alt = "^%s*async%s+def%s+",
ending = nil, -- Python uses indentation
},
javascript = {
start = "^%s*function%s+",
start_alt = "^%s*const%s+%w+%s*=%s*",
ending = "^%s*}%s*$",
},
typescript = {
start = "^%s*function%s+",
start_alt = "^%s*const%s+%w+%s*=%s*",
ending = "^%s*}%s*$",
},
}
local lang_patterns = patterns[filetype]
if not lang_patterns then
return nil
end
-- Find function start (search backwards)
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
start_line = i
break
end
end
if not start_line then
return nil
end
-- Find function end
local end_line = nil
if lang_patterns.ending then
-- Brace/end based languages
local depth = 0
for i = start_line, #lines do
local line = lines[i]
-- Count braces or end keywords
if filetype == "lua" then
if line:match("function") or line:match("if") or line:match("for") or line:match("while") then
depth = depth + 1
end
if line:match("^%s*end") then
depth = depth - 1
if depth <= 0 then
end_line = i
break
end
end
else
-- JavaScript/TypeScript brace counting
for _ in line:gmatch("{") do depth = depth + 1 end
for _ in line:gmatch("}") do depth = depth - 1 end
if depth <= 0 and i > start_line then
end_line = i
break
end
end
end
else
-- Python: use indentation
local base_indent = #(lines[start_line]:match("^%s*") or "")
for i = start_line + 1, #lines do
local line = lines[i]
if line:match("^%s*$") then
goto continue
end
local indent = #(line:match("^%s*") or "")
if indent <= base_indent then
end_line = i - 1
break
end
::continue::
end
end_line = end_line or #lines
end
if not end_line then
end_line = #lines
end
-- Extract text
local scope_lines = {}
for i = start_line, end_line do
table.insert(scope_lines, lines[i])
end
-- Try to extract function name
local name = nil
local first_line = lines[start_line]
name = first_line:match("function%s+([%w_]+)") or
first_line:match("def%s+([%w_]+)") or
first_line:match("const%s+([%w_]+)")
return {
type = "function",
node_type = "heuristic",
range = {
start_row = start_line,
start_col = 0,
end_row = end_line,
end_col = #lines[end_line],
},
text = table.concat(scope_lines, "\n"),
name = name,
}
end
--- Get scope for the current cursor position
---@return ScopeInfo
function M.resolve_scope_at_cursor()
local bufnr = vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
return M.resolve_scope(bufnr, cursor[1], cursor[2] + 1)
end
--- Check if position is inside a function/method
---@param bufnr number
---@param row number 1-indexed
---@param col number 1-indexed
---@return boolean
function M.is_in_function(bufnr, row, col)
local scope = M.resolve_scope(bufnr, row, col)
return scope.type == "function" or scope.type == "method"
end
--- Get all functions in buffer
---@param bufnr number
---@return ScopeInfo[]
function M.get_all_functions(bufnr)
local functions = {}
if not M.has_treesitter(bufnr) then
return functions
end
local parser = vim.treesitter.get_parser(bufnr)
if not parser then
return functions
end
local tree = parser:parse()[1]
if not tree then
return functions
end
local root = tree:root()
-- Query for all function nodes
local lang = parser:lang()
local query_string = [[
(function_declaration) @func
(function_definition) @func
(method_definition) @func
(arrow_function) @func
]]
local ok, query = pcall(vim.treesitter.query.parse, lang, query_string)
if not ok then
return functions
end
for _, node in query:iter_captures(root, bufnr, 0, -1) do
local start_row, start_col, end_row, end_col = node:range()
local text = vim.treesitter.get_node_text(node, bufnr)
local name = get_scope_name(node, bufnr)
table.insert(functions, {
type = function_nodes[node:type()] or "function",
node_type = node:type(),
range = {
start_row = start_row + 1,
start_col = start_col,
end_row = end_row + 1,
end_col = end_col,
},
text = text,
name = name,
})
end
return functions
end
return M

View File

@@ -0,0 +1,161 @@
---@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" },
},
},
}
--- 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

674
lua/codetyper/agent/ui.lua Normal file
View File

@@ -0,0 +1,674 @@
---@mod codetyper.agent.ui Agent chat UI for Codetyper.nvim
---
--- Provides a sidebar chat interface for agent interactions with real-time logs.
local M = {}
local agent = require("codetyper.agent")
local logs = require("codetyper.agent.logs")
local utils = require("codetyper.utils")
---@class AgentUIState
---@field chat_buf number|nil Chat buffer
---@field chat_win number|nil Chat window
---@field input_buf number|nil Input buffer
---@field input_win number|nil Input window
---@field logs_buf number|nil Logs buffer
---@field logs_win number|nil Logs window
---@field is_open boolean Whether the UI is open
---@field log_listener_id number|nil Listener ID for logs
---@field referenced_files table Files referenced with @
local state = {
chat_buf = nil,
chat_win = nil,
input_buf = nil,
input_win = nil,
logs_buf = nil,
logs_win = nil,
is_open = false,
log_listener_id = nil,
referenced_files = {},
}
--- Namespace for highlights
local ns_chat = vim.api.nvim_create_namespace("codetyper_agent_chat")
local ns_logs = vim.api.nvim_create_namespace("codetyper_agent_logs")
--- Fixed widths
local CHAT_WIDTH = 300
local LOGS_WIDTH = 50
local INPUT_HEIGHT = 5
--- Autocmd group
local agent_augroup = nil
--- Add a log entry to the logs buffer
---@param entry table Log entry
local function add_log_entry(entry)
if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then
return
end
vim.schedule(function()
if not state.logs_buf or not vim.api.nvim_buf_is_valid(state.logs_buf) then
return
end
-- Handle clear event
if entry.level == "clear" then
vim.bo[state.logs_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, {
"Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.logs_buf].modifiable = false
return
end
vim.bo[state.logs_buf].modifiable = true
local formatted = logs.format_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 })
-- Apply highlighting based on level
local hl_map = {
info = "DiagnosticInfo",
debug = "Comment",
request = "DiagnosticWarn",
response = "DiagnosticOk",
tool = "DiagnosticHint",
error = "DiagnosticError",
}
local hl = hl_map[entry.level] or "Normal"
vim.api.nvim_buf_add_highlight(state.logs_buf, ns_logs, hl, line_num, 0, -1)
vim.bo[state.logs_buf].modifiable = false
-- Auto-scroll logs
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
local new_count = vim.api.nvim_buf_line_count(state.logs_buf)
pcall(vim.api.nvim_win_set_cursor, state.logs_win, { new_count, 0 })
end
end)
end
--- Add a message to the chat buffer
---@param role string "user" | "assistant" | "tool" | "system"
---@param content string Message content
---@param highlight? string Optional highlight group
local function add_message(role, content, highlight)
if not state.chat_buf or not vim.api.nvim_buf_is_valid(state.chat_buf) then
return
end
vim.bo[state.chat_buf].modifiable = true
local lines = vim.api.nvim_buf_get_lines(state.chat_buf, 0, -1, false)
local start_line = #lines
-- Add separator if not first message
if start_line > 0 and lines[start_line] ~= "" then
vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, { "" })
start_line = start_line + 1
end
-- Format the message
local prefix_map = {
user = ">>> You:",
assistant = "<<< Agent:",
tool = "[Tool]",
system = "[System]",
}
local prefix = prefix_map[role] or "[Unknown]"
local message_lines = { prefix }
-- Split content into lines
for line in content:gmatch("[^\n]+") do
table.insert(message_lines, " " .. line)
end
vim.api.nvim_buf_set_lines(state.chat_buf, -1, -1, false, message_lines)
-- Apply highlighting
local hl_group = highlight or ({
user = "DiagnosticInfo",
assistant = "DiagnosticOk",
tool = "DiagnosticWarn",
system = "DiagnosticHint",
})[role] or "Normal"
vim.api.nvim_buf_add_highlight(state.chat_buf, ns_chat, hl_group, start_line, 0, -1)
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)
pcall(vim.api.nvim_win_set_cursor, state.chat_win, { line_count, 0 })
end
end
--- Create the agent callbacks
---@return table Callbacks for agent.run
local function create_callbacks()
return {
on_text = function(text)
vim.schedule(function()
add_message("assistant", text)
logs.thinking("Received response text")
end)
end,
on_tool_start = function(name)
vim.schedule(function()
add_message("tool", "Executing: " .. name .. "...", "DiagnosticWarn")
logs.tool(name, "start")
end)
end,
on_tool_result = function(name, result)
vim.schedule(function()
local display_result = result
if #result > 200 then
display_result = result:sub(1, 200) .. "..."
end
add_message("tool", name .. ": " .. display_result, "DiagnosticOk")
logs.tool(name, "success", string.format("%d bytes", #result))
end)
end,
on_complete = function()
vim.schedule(function()
add_message("system", "Done.", "DiagnosticHint")
logs.info("Agent loop completed")
M.focus_input()
end)
end,
on_error = function(err)
vim.schedule(function()
add_message("system", "Error: " .. err, "DiagnosticError")
logs.error(err)
M.focus_input()
end)
end,
}
end
--- Build file context from referenced files
---@return string Context string
local function build_file_context()
local context = ""
for filename, filepath in pairs(state.referenced_files) do
local content = utils.read_file(filepath)
if content and content ~= "" then
local ext = vim.fn.fnamemodify(filepath, ":e")
context = context .. "\n\n=== FILE: " .. filename .. " ===\n"
context = context .. "Path: " .. filepath .. "\n"
context = context .. "```" .. (ext or "text") .. "\n" .. content .. "\n```\n"
end
end
return context
end
--- Submit user input
local function submit_input()
if not state.input_buf or not vim.api.nvim_buf_is_valid(state.input_buf) then
return
end
local lines = vim.api.nvim_buf_get_lines(state.input_buf, 0, -1, false)
local input = table.concat(lines, "\n")
input = vim.trim(input)
if input == "" then
return
end
-- Clear input buffer
vim.api.nvim_buf_set_lines(state.input_buf, 0, -1, false, { "" })
-- Handle special commands
if input == "/stop" then
agent.stop()
add_message("system", "Stopped.")
logs.info("Agent stopped by user")
return
end
if input == "/clear" then
agent.reset()
logs.clear()
state.referenced_files = {}
if state.chat_buf and vim.api.nvim_buf_is_valid(state.chat_buf) then
vim.bo[state.chat_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, {
"╔═══════════════════════════════════════════════════════════════╗",
"║ [AGENT MODE] Can read/write files ║",
"╠═══════════════════════════════════════════════════════════════╣",
"║ @ attach file | C-f current file | :CoderType switch mode ║",
"╚═══════════════════════════════════════════════════════════════╝",
"",
})
vim.bo[state.chat_buf].modifiable = false
end
return
end
if input == "/close" then
M.close()
return
end
-- Build file context
local file_context = build_file_context()
local file_count = vim.tbl_count(state.referenced_files)
-- Add user message to chat
local display_input = input
if file_count > 0 then
local files_list = {}
for fname, _ in pairs(state.referenced_files) do
table.insert(files_list, fname)
end
display_input = input .. "\n[Attached: " .. table.concat(files_list, ", ") .. "]"
end
add_message("user", display_input)
logs.info("User: " .. input:sub(1, 40) .. (input:len() > 40 and "..." or ""))
-- Clear referenced files after use
state.referenced_files = {}
-- Check if agent is already running
if agent.is_running() then
add_message("system", "Busy. /stop first.")
logs.info("Request rejected - busy")
return
end
-- Build context from current buffer
local current_file = vim.fn.expand("#:p")
if current_file == "" then
current_file = vim.fn.expand("%:p")
end
local llm = require("codetyper.llm")
local context = {}
if current_file ~= "" and vim.fn.filereadable(current_file) == 1 then
context = llm.build_context(current_file, "agent")
logs.debug("Context: " .. vim.fn.fnamemodify(current_file, ":t"))
end
-- Append file context to input
local full_input = input
if file_context ~= "" then
full_input = input .. "\n\nATTACHED FILES:" .. file_context
end
logs.thinking("Starting...")
-- Run the agent
agent.run(full_input, context, create_callbacks())
end
--- Show file picker for @ mentions
function M.show_file_picker()
local has_telescope, telescope = pcall(require, "telescope.builtin")
if has_telescope then
telescope.find_files({
prompt_title = "Attach file (@)",
attach_mappings = function(prompt_bufnr, map)
local actions = require("telescope.actions")
local action_state = require("telescope.actions.state")
actions.select_default:replace(function()
actions.close(prompt_bufnr)
local selection = action_state.get_selected_entry()
if selection then
local filepath = selection.path or selection[1]
local filename = vim.fn.fnamemodify(filepath, ":t")
M.add_file_reference(filepath, filename)
end
end)
return true
end,
})
else
vim.ui.input({ prompt = "File path: " }, function(input)
if input and input ~= "" then
local filepath = vim.fn.fnamemodify(input, ":p")
local filename = vim.fn.fnamemodify(filepath, ":t")
M.add_file_reference(filepath, filename)
end
end)
end
end
--- Add a file reference
---@param filepath string Full path to the file
---@param filename string Display name
function M.add_file_reference(filepath, filename)
filepath = vim.fn.fnamemodify(filepath, ":p")
state.referenced_files[filename] = filepath
local content = utils.read_file(filepath)
if not content then
utils.notify("Cannot read: " .. filename, vim.log.levels.WARN)
return
end
add_message("system", "Attached: " .. filename, "DiagnosticHint")
logs.debug("Attached: " .. filename)
M.focus_input()
end
--- Include current file context
function M.include_current_file()
-- Get the file from the window that's not the agent sidebar
local current_file = nil
for _, win in ipairs(vim.api.nvim_list_wins()) do
if win ~= state.chat_win and win ~= state.logs_win and win ~= state.input_win then
local buf = vim.api.nvim_win_get_buf(win)
local name = vim.api.nvim_buf_get_name(buf)
if name ~= "" and vim.fn.filereadable(name) == 1 then
current_file = name
break
end
end
end
if not current_file then
utils.notify("No file to attach", vim.log.levels.WARN)
return
end
local filename = vim.fn.fnamemodify(current_file, ":t")
M.add_file_reference(current_file, filename)
end
--- Focus the input buffer
function M.focus_input()
if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then
vim.api.nvim_set_current_win(state.input_win)
vim.cmd("startinsert")
end
end
--- Focus the chat buffer
function M.focus_chat()
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
vim.api.nvim_set_current_win(state.chat_win)
end
end
--- Focus the logs buffer
function M.focus_logs()
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
vim.api.nvim_set_current_win(state.logs_win)
end
end
--- Show chat mode switcher modal
function M.show_chat_switcher()
local switcher = require("codetyper.chat_switcher")
switcher.show()
end
--- Update the logs title with token counts
local function update_logs_title()
if not state.logs_win or not vim.api.nvim_win_is_valid(state.logs_win) then
return
end
local prompt_tokens, response_tokens = logs.get_token_totals()
local provider, _ = logs.get_provider_info()
if provider and state.logs_buf and vim.api.nvim_buf_is_valid(state.logs_buf) then
vim.bo[state.logs_buf].modifiable = true
local lines = vim.api.nvim_buf_get_lines(state.logs_buf, 0, 2, false)
if #lines >= 1 then
lines[1] = string.format("%s | %d/%d tokens", provider:upper(), prompt_tokens, response_tokens)
vim.api.nvim_buf_set_lines(state.logs_buf, 0, 1, false, { lines[1] })
end
vim.bo[state.logs_buf].modifiable = false
end
end
--- Open the agent UI
function M.open()
if state.is_open then
M.focus_input()
return
end
-- Clear previous state
logs.clear()
state.referenced_files = {}
-- Create chat buffer
state.chat_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.chat_buf].buftype = "nofile"
vim.bo[state.chat_buf].bufhidden = "hide"
vim.bo[state.chat_buf].swapfile = false
vim.bo[state.chat_buf].filetype = "markdown"
-- Create input buffer
state.input_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.input_buf].buftype = "nofile"
vim.bo[state.input_buf].bufhidden = "hide"
vim.bo[state.input_buf].swapfile = false
-- Create logs buffer
state.logs_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.logs_buf].buftype = "nofile"
vim.bo[state.logs_buf].bufhidden = "hide"
vim.bo[state.logs_buf].swapfile = false
-- Create chat window on the LEFT (like NvimTree)
vim.cmd("topleft vsplit")
state.chat_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.chat_win, state.chat_buf)
vim.api.nvim_win_set_width(state.chat_win, CHAT_WIDTH)
-- Window options for chat
vim.wo[state.chat_win].number = false
vim.wo[state.chat_win].relativenumber = false
vim.wo[state.chat_win].signcolumn = "no"
vim.wo[state.chat_win].wrap = true
vim.wo[state.chat_win].linebreak = true
vim.wo[state.chat_win].winfixwidth = true
vim.wo[state.chat_win].cursorline = false
-- Create input window below chat
vim.cmd("belowright split")
state.input_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.input_win, state.input_buf)
vim.api.nvim_win_set_height(state.input_win, INPUT_HEIGHT)
-- Window options for input
vim.wo[state.input_win].number = false
vim.wo[state.input_win].relativenumber = false
vim.wo[state.input_win].signcolumn = "no"
vim.wo[state.input_win].wrap = true
vim.wo[state.input_win].linebreak = true
vim.wo[state.input_win].winfixheight = true
vim.wo[state.input_win].winfixwidth = true
-- Create logs window on the RIGHT
vim.cmd("botright vsplit")
state.logs_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.logs_win, state.logs_buf)
vim.api.nvim_win_set_width(state.logs_win, LOGS_WIDTH)
-- Window options for logs
vim.wo[state.logs_win].number = false
vim.wo[state.logs_win].relativenumber = false
vim.wo[state.logs_win].signcolumn = "no"
vim.wo[state.logs_win].wrap = true
vim.wo[state.logs_win].linebreak = true
vim.wo[state.logs_win].winfixwidth = true
vim.wo[state.logs_win].cursorline = false
-- Set initial content for chat
vim.bo[state.chat_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.chat_buf, 0, -1, false, {
"╔═══════════════════════════════════════════════════════════════╗",
"║ [AGENT MODE] Can read/write files ║",
"╠═══════════════════════════════════════════════════════════════╣",
"║ @ attach file | C-f current file | :CoderType switch mode ║",
"╚═══════════════════════════════════════════════════════════════╝",
"",
})
vim.bo[state.chat_buf].modifiable = false
-- Set initial content for logs
vim.bo[state.logs_buf].modifiable = true
vim.api.nvim_buf_set_lines(state.logs_buf, 0, -1, false, {
"Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.logs_buf].modifiable = false
-- Register log listener
state.log_listener_id = logs.add_listener(function(entry)
add_log_entry(entry)
if entry.level == "response" then
vim.schedule(update_logs_title)
end
end)
-- Set up keymaps for input buffer
local input_opts = { buffer = state.input_buf, noremap = true, silent = true }
vim.keymap.set("i", "<CR>", submit_input, input_opts)
vim.keymap.set("n", "<CR>", submit_input, input_opts)
vim.keymap.set("i", "@", M.show_file_picker, input_opts)
vim.keymap.set({ "n", "i" }, "<C-f>", M.include_current_file, input_opts)
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)
-- Set up keymaps for chat buffer
local chat_opts = { buffer = state.chat_buf, noremap = true, silent = true }
vim.keymap.set("n", "i", M.focus_input, chat_opts)
vim.keymap.set("n", "<CR>", M.focus_input, chat_opts)
vim.keymap.set("n", "@", M.show_file_picker, chat_opts)
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)
-- Set up keymaps for logs buffer
local logs_opts = { buffer = state.logs_buf, noremap = true, silent = true }
vim.keymap.set("n", "<Tab>", M.focus_input, logs_opts)
vim.keymap.set("n", "q", M.close, logs_opts)
vim.keymap.set("n", "i", M.focus_input, logs_opts)
-- Setup autocmd for cleanup
agent_augroup = vim.api.nvim_create_augroup("CodetypeAgentUI", { clear = true })
vim.api.nvim_create_autocmd("WinClosed", {
group = agent_augroup,
callback = function(args)
local closed_win = tonumber(args.match)
if closed_win == state.chat_win or closed_win == state.logs_win or closed_win == state.input_win then
vim.schedule(function()
M.close()
end)
end
end,
})
state.is_open = true
-- Focus input and log startup
M.focus_input()
logs.info("Agent ready")
-- Log provider info
local ok, codetyper = pcall(require, "codetyper")
if ok then
local config = codetyper.get_config()
local provider = config.llm.provider
local model = provider == "claude" and config.llm.claude.model or config.llm.ollama.model
logs.info(string.format("%s (%s)", provider, model))
end
end
--- Close the agent UI
function M.close()
if not state.is_open then
return
end
-- Stop agent if running
if agent.is_running() then
agent.stop()
end
-- Remove log listener
if state.log_listener_id then
logs.remove_listener(state.log_listener_id)
state.log_listener_id = nil
end
-- Remove autocmd
if agent_augroup then
pcall(vim.api.nvim_del_augroup_by_id, agent_augroup)
agent_augroup = nil
end
-- Close windows
if state.input_win and vim.api.nvim_win_is_valid(state.input_win) then
pcall(vim.api.nvim_win_close, state.input_win, true)
end
if state.chat_win and vim.api.nvim_win_is_valid(state.chat_win) then
pcall(vim.api.nvim_win_close, state.chat_win, true)
end
if state.logs_win and vim.api.nvim_win_is_valid(state.logs_win) then
pcall(vim.api.nvim_win_close, state.logs_win, true)
end
-- Reset state
state.chat_buf = nil
state.chat_win = nil
state.input_buf = nil
state.input_win = nil
state.logs_buf = nil
state.logs_win = nil
state.is_open = false
state.referenced_files = {}
-- Reset agent conversation
agent.reset()
end
--- Toggle the agent UI
function M.toggle()
if state.is_open then
M.close()
else
M.open()
end
end
--- Check if UI is open
---@return boolean
function M.is_open()
return state.is_open
end
return M

View File

@@ -0,0 +1,659 @@
---@mod codetyper.agent.worker Async LLM worker wrapper
---@brief [[
--- Wraps LLM clients with timeout handling and confidence scoring.
--- Provides unified interface for scheduler to dispatch work.
---@brief ]]
local M = {}
local confidence = require("codetyper.agent.confidence")
---@class WorkerResult
---@field success boolean Whether the request succeeded
---@field response string|nil The generated code
---@field error string|nil Error message if failed
---@field confidence number Confidence score (0.0-1.0)
---@field confidence_breakdown table Detailed confidence breakdown
---@field duration number Time taken in seconds
---@field worker_type string LLM provider used
---@field usage table|nil Token usage if available
---@class Worker
---@field id string Worker ID
---@field event table PromptEvent being processed
---@field worker_type string LLM provider type
---@field status string "pending"|"running"|"completed"|"failed"|"timeout"
---@field start_time number Start timestamp
---@field timeout_ms number Timeout in milliseconds
---@field timer any Timeout timer handle
---@field callback function Result callback
--- Worker ID counter
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",
}
--- Check if response indicates need for more context
--- Only triggers if the response primarily asks for context (no substantial code)
---@param response string
---@return boolean
local function needs_more_context(response)
if not response then
return false
end
-- If response has substantial code (more than 5 lines with code-like content), don't ask for context
local lines = vim.split(response, "\n")
local code_lines = 0
for _, line in ipairs(lines) do
-- Count lines that look like code (have programming constructs)
if line:match("[{}();=]") or line:match("function") or line:match("def ")
or line:match("class ") or line:match("return ") or line:match("import ")
or line:match("public ") or line:match("private ") or line:match("local ") then
code_lines = code_lines + 1
end
end
-- If there's substantial code, don't trigger context request
if code_lines >= 3 then
return false
end
-- Check if the response STARTS with a context-needed phrase
local lower = response:lower()
for _, pattern in ipairs(context_needed_patterns) do
if lower:match(pattern) then
return true
end
end
return false
end
--- Clean LLM response to extract only code
---@param response string Raw LLM response
---@param filetype string|nil File type for language detection
---@return string Cleaned code
local function clean_response(response, filetype)
if not response then
return ""
end
local cleaned = response
-- Remove LLM special tokens (deepseek, llama, etc.)
cleaned = cleaned:gsub("<begin▁of▁sentence>", "")
cleaned = cleaned:gsub("<end▁of▁sentence>", "")
cleaned = cleaned:gsub("<|im_start|>", "")
cleaned = cleaned:gsub("<|im_end|>", "")
cleaned = cleaned:gsub("<s>", "")
cleaned = cleaned:gsub("</s>", "")
cleaned = cleaned:gsub("<|endoftext|>", "")
-- Remove the original prompt tags /@ ... @/ if they appear in output
-- Use [%s%S] to match any character including newlines (Lua's . doesn't match newlines)
cleaned = cleaned:gsub("/@[%s%S]-@/", "")
-- Try to extract code from markdown code blocks
-- Match ```language\n...\n``` or just ```\n...\n```
local code_block = cleaned:match("```[%w]*\n(.-)\n```")
if not code_block then
-- Try without newline after language
code_block = cleaned:match("```[%w]*(.-)\n```")
end
if not code_block then
-- Try single line code block
code_block = cleaned:match("```(.-)```")
end
if code_block then
cleaned = code_block
else
-- No code block found, try to remove common prefixes/suffixes
-- Remove common apology/explanation phrases at the start
local explanation_starts = {
"^[Ii]'m sorry.-\n",
"^[Ii] apologize.-\n",
"^[Hh]ere is.-:\n",
"^[Hh]ere's.-:\n",
"^[Tt]his is.-:\n",
"^[Bb]ased on.-:\n",
"^[Ss]ure.-:\n",
"^[Oo][Kk].-:\n",
"^[Cc]ertainly.-:\n",
}
for _, pattern in ipairs(explanation_starts) do
cleaned = cleaned:gsub(pattern, "")
end
-- Remove trailing explanations
local explanation_ends = {
"\n[Tt]his code.-$",
"\n[Tt]his function.-$",
"\n[Tt]his is a.-$",
"\n[Ii] hope.-$",
"\n[Ll]et me know.-$",
"\n[Ff]eel free.-$",
"\n[Nn]ote:.-$",
"\n[Pp]lease replace.-$",
"\n[Pp]lease note.-$",
"\n[Yy]ou might want.-$",
"\n[Yy]ou may want.-$",
"\n[Mm]ake sure.-$",
"\n[Aa]lso,.-$",
"\n[Rr]emember.-$",
}
for _, pattern in ipairs(explanation_ends) do
cleaned = cleaned:gsub(pattern, "")
end
end
-- Remove any remaining markdown artifacts
cleaned = cleaned:gsub("^```[%w]*\n?", "")
cleaned = cleaned:gsub("\n?```$", "")
-- Trim whitespace
cleaned = cleaned:match("^%s*(.-)%s*$") or cleaned
return cleaned
end
--- Active workers
---@type table<string, Worker>
local active_workers = {}
--- Default timeouts by provider type
local default_timeouts = {
ollama = 30000, -- 30s for local
claude = 60000, -- 60s for remote
openai = 60000,
gemini = 60000,
copilot = 60000,
}
--- Generate worker ID
---@return string
local function generate_id()
worker_counter = worker_counter + 1
return string.format("worker_%d_%d", os.time(), worker_counter)
end
--- Get LLM client by type
---@param worker_type string
---@return table|nil client
---@return string|nil error
local function get_client(worker_type)
local ok, client = pcall(require, "codetyper.llm." .. worker_type)
if ok and client then
return client, nil
end
return nil, "Unknown provider: " .. worker_type
end
--- Format attached files for inclusion in prompt
---@param attached_files table[]|nil
---@return string
local function format_attached_files(attached_files)
if not attached_files or #attached_files == 0 then
return ""
end
local parts = { "\n\n--- Referenced Files ---" }
for _, file in ipairs(attached_files) do
local ext = vim.fn.fnamemodify(file.path, ":e")
table.insert(parts, string.format(
"\n\nFile: %s\n```%s\n%s\n```",
file.path,
ext,
file.content:sub(1, 3000) -- Limit each file to 3000 chars
))
end
return table.concat(parts, "")
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")
-- Get target file content for context
local target_content = ""
if event.target_path then
local ok, lines = pcall(function()
return vim.fn.readfile(event.target_path)
end)
if ok and lines then
target_content = table.concat(lines, "\n")
end
end
local filetype = vim.fn.fnamemodify(event.target_path or "", ":e")
-- Format attached files
local attached_content = format_attached_files(event.attached_files)
-- Build context with scope information
local context = {
target_path = event.target_path,
target_content = target_content,
filetype = filetype,
scope = event.scope,
scope_text = event.scope_text,
scope_range = event.scope_range,
intent = event.intent,
attached_files = event.attached_files,
}
-- Build the actual prompt based on intent and scope
local system_prompt = ""
local user_prompt = event.prompt_content
if event.intent then
system_prompt = intent_mod.get_prompt_modifier(event.intent)
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
local scope_name = event.scope.name or "anonymous"
-- Special handling for "complete" intent - fill in the function body
if event.intent and event.intent.type == "complete" then
user_prompt = string.format(
[[Complete this %s. Fill in the implementation based on the description.
IMPORTANT:
- Keep the EXACT same function signature (name, parameters, return type)
- Only provide the COMPLETE function with implementation
- Do NOT create a new function or duplicate the signature
- Do NOT add any text before or after the function
Current %s (incomplete):
```%s
%s
```
%s
What it should do: %s
Return ONLY the complete %s with implementation. No explanations, no duplicates.]],
scope_type,
scope_type,
filetype,
event.scope_text,
attached_content,
event.prompt_content,
scope_type
)
-- 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(
[[Here is a %s named "%s" in a %s file:
```%s
%s
```
%s
User request: %s
Return the complete transformed %s. Output only code, no explanations.]],
scope_type,
scope_name,
filetype,
filetype,
event.scope_text,
attached_content,
event.prompt_content,
scope_type
)
else
-- For insertion intents, provide context
user_prompt = string.format(
[[Context - this code is inside a %s named "%s":
```%s
%s
```
%s
User request: %s
Output only the code to insert, no explanations.]],
scope_type,
scope_name,
filetype,
event.scope_text,
attached_content,
event.prompt_content
)
end
else
-- No scope resolved, use full file context
user_prompt = string.format(
[[File: %s (%s)
```%s
%s
```
%s
User request: %s
Output only code, no explanations.]],
vim.fn.fnamemodify(event.target_path or "", ":t"),
filetype,
filetype,
target_content:sub(1, 4000), -- Limit context size
attached_content,
event.prompt_content
)
end
context.system_prompt = system_prompt
context.formatted_prompt = user_prompt
return user_prompt, context
end
--- Create and start a worker
---@param event table PromptEvent
---@param worker_type string LLM provider type
---@param callback function(result: WorkerResult)
---@return Worker
function M.create(event, worker_type, callback)
local worker = {
id = generate_id(),
event = event,
worker_type = worker_type,
status = "pending",
start_time = os.clock(),
timeout_ms = default_timeouts[worker_type] or 60000,
callback = callback,
}
active_workers[worker.id] = worker
-- Log worker creation
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "worker",
message = string.format("Worker %s started (%s)", worker.id, worker_type),
data = {
worker_id = worker.id,
event_id = event.id,
provider = worker_type,
},
})
end)
-- Start the work
M.start(worker)
return worker
end
--- Start worker execution
---@param worker Worker
function M.start(worker)
worker.status = "running"
-- Set up timeout
worker.timer = vim.defer_fn(function()
if worker.status == "running" then
worker.status = "timeout"
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "warning",
message = string.format("Worker %s timed out after %dms", worker.id, worker.timeout_ms),
})
end)
worker.callback({
success = false,
response = nil,
error = "timeout",
confidence = 0,
confidence_breakdown = {},
duration = (os.clock() - worker.start_time),
worker_type = worker.worker_type,
})
end
end, worker.timeout_ms)
-- Get client and execute
local client, client_err = get_client(worker.worker_type)
if not client then
M.complete(worker, nil, client_err)
return
end
local prompt, context = build_prompt(worker.event)
-- Call the LLM
client.generate(prompt, context, function(response, err, usage)
-- Cancel timeout timer
if worker.timer then
pcall(function()
-- Timer might have already fired
if type(worker.timer) == "userdata" and worker.timer.stop then
worker.timer:stop()
end
end)
end
if worker.status ~= "running" then
return -- Already timed out or cancelled
end
M.complete(worker, response, err, usage)
end)
end
--- Complete worker execution
---@param worker Worker
---@param response string|nil
---@param error string|nil
---@param usage table|nil
function M.complete(worker, response, error, usage)
local duration = os.clock() - worker.start_time
if error then
worker.status = "failed"
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "error",
message = string.format("Worker %s failed: %s", worker.id, error),
})
end)
worker.callback({
success = false,
response = nil,
error = error,
confidence = 0,
confidence_breakdown = {},
duration = duration,
worker_type = worker.worker_type,
usage = usage,
})
return
end
-- Check if LLM needs more context
if needs_more_context(response) then
worker.status = "needs_context"
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = string.format("Worker %s: LLM needs more context", worker.id),
})
end)
worker.callback({
success = false,
response = response,
error = nil,
needs_context = true,
original_event = worker.event,
confidence = 0,
confidence_breakdown = {},
duration = duration,
worker_type = worker.worker_type,
usage = usage,
})
return
end
-- Log the full raw LLM response (for debugging)
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "response",
message = "--- LLM Response ---",
data = {
raw_response = response,
},
})
end)
-- Clean the response (remove markdown, explanations, etc.)
local filetype = vim.fn.fnamemodify(worker.event.target_path or "", ":e")
local cleaned_response = clean_response(response, filetype)
-- Score confidence on cleaned response
local conf_score, breakdown = confidence.score(cleaned_response, worker.event.prompt_content)
worker.status = "completed"
active_workers[worker.id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "success",
message = string.format(
"Worker %s completed (%.2fs, confidence: %.2f - %s)",
worker.id, duration, conf_score, confidence.level_name(conf_score)
),
data = {
confidence_breakdown = confidence.format_breakdown(breakdown),
usage = usage,
},
})
end)
worker.callback({
success = true,
response = cleaned_response,
error = nil,
confidence = conf_score,
confidence_breakdown = breakdown,
duration = duration,
worker_type = worker.worker_type,
usage = usage,
})
end
--- Cancel a worker
---@param worker_id string
---@return boolean
function M.cancel(worker_id)
local worker = active_workers[worker_id]
if not worker then
return false
end
if worker.timer then
pcall(function()
if type(worker.timer) == "userdata" and worker.timer.stop then
worker.timer:stop()
end
end)
end
worker.status = "cancelled"
active_workers[worker_id] = nil
pcall(function()
local logs = require("codetyper.agent.logs")
logs.add({
type = "info",
message = string.format("Worker %s cancelled", worker_id),
})
end)
return true
end
--- Get active worker count
---@return number
function M.active_count()
local count = 0
for _ in pairs(active_workers) do
count = count + 1
end
return count
end
--- Get all active workers
---@return Worker[]
function M.get_active()
local workers = {}
for _, worker in pairs(active_workers) do
table.insert(workers, worker)
end
return workers
end
--- Check if worker exists and is running
---@param worker_id string
---@return boolean
function M.is_running(worker_id)
local worker = active_workers[worker_id]
return worker ~= nil and worker.status == "running"
end
--- Cancel all workers for an event
---@param event_id string
---@return number cancelled_count
function M.cancel_for_event(event_id)
local cancelled = 0
for id, worker in pairs(active_workers) do
if worker.event.id == event_id then
M.cancel(id)
cancelled = cancelled + 1
end
end
return cancelled
end
--- Set timeout for worker type
---@param worker_type string
---@param timeout_ms number
function M.set_timeout(worker_type, timeout_ms)
default_timeouts[worker_type] = timeout_ms
end
return M

View File

@@ -24,6 +24,8 @@ local state = {
referenced_files = {},
target_width = nil, -- Store the target width to maintain it
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
}
--- Get the ask window configuration
@@ -50,19 +52,19 @@ local function create_output_buffer()
-- Set initial content
local header = {
"╔═════════════════════════════╗",
"🤖 CODETYPER ASK",
"╠═════════════════════════════╣",
"║ Ask about code or concepts ║",
"║ ║",
"💡 Keymaps:",
"@ → attach file",
"║ C-Enter → send",
"║ C-nnew chat ",
"C-f → add current file",
"C-h/j/k/l → navigate ║",
"║ q → close │ K/J → jump ║",
"╚═════════════════════════════╝",
"╔═════════════════════════════════",
" [ASK MODE] Q&A Chat",
"╠═════════════════════════════════",
"║ Ask about code or concepts ",
" ",
"@ → attach file",
"C-Enter → send ",
"║ C-n → new chat ",
"║ C-fadd current file",
"L → toggle LLM logs ",
":CoderType → switch mode ║",
"║ q → close │ K/J → jump ",
"╚═════════════════════════════════",
"",
}
vim.api.nvim_buf_set_lines(buf, 0, -1, false, header)
@@ -196,6 +198,11 @@ local function setup_output_keymaps(buf)
M.copy_last_response()
end, opts)
-- Toggle LLM logs with L
vim.keymap.set("n", "L", function()
M.toggle_logs()
end, opts)
-- Jump to input with i or J
vim.keymap.set("n", "i", function()
M.focus_input()
@@ -277,6 +284,88 @@ local function setup_width_autocmd()
})
end
--- Append log entry to output buffer
---@param entry table Log entry from agent/logs
local function append_log_to_output(entry)
if not state.show_logs then
return
end
if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then
return
end
-- Skip clear events
if entry.level == "clear" then
return
end
-- Format the log entry with icons
local icons = {
info = "",
debug = "🔍",
request = "📤",
response = "📥",
tool = "🔧",
error = "",
warning = "⚠️",
}
local icon = icons[entry.level] or ""
local formatted = string.format("[%s] %s %s", entry.timestamp, icon, entry.message)
vim.schedule(function()
if not state.output_buf or not vim.api.nvim_buf_is_valid(state.output_buf) then
return
end
vim.bo[state.output_buf].modifiable = true
local lines = vim.api.nvim_buf_get_lines(state.output_buf, 0, -1, false)
-- Add a subtle log line
table.insert(lines, " " .. formatted)
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)
pcall(vim.api.nvim_win_set_cursor, state.output_win, { line_count, 0 })
end
end)
end
--- Setup log listener for LLM logs
local function setup_log_listener()
-- Remove existing listener if any
if state.log_listener_id then
pcall(function()
local logs = require("codetyper.agent.logs")
logs.remove_listener(state.log_listener_id)
end)
state.log_listener_id = nil
end
-- Add new listener
local ok, logs = pcall(require, "codetyper.agent.logs")
if ok then
state.log_listener_id = logs.add_listener(append_log_to_output)
end
end
--- Remove log listener
local function remove_log_listener()
if state.log_listener_id then
pcall(function()
local logs = require("codetyper.agent.logs")
logs.remove_listener(state.log_listener_id)
end)
state.log_listener_id = nil
end
end
--- Open the ask panel
function M.open()
-- Use the is_open() function which validates window state
@@ -336,6 +425,9 @@ function M.open()
state.is_open = true
-- Setup log listener for LLM logs
setup_log_listener()
-- Setup autocmd to maintain width
setup_width_autocmd()
@@ -367,6 +459,9 @@ function M.open()
state.is_open = false
state.target_width = nil
-- Remove log listener
remove_log_listener()
-- Clean up autocmd groups
pcall(vim.api.nvim_del_augroup_by_id, close_group)
if ask_augroup then
@@ -467,6 +562,9 @@ end
--- Close the ask panel
function M.close()
-- Remove the log listener
remove_log_listener()
-- Remove the width maintenance autocmd first
if ask_augroup then
pcall(vim.api.nvim_del_augroup_by_id, ask_augroup)
@@ -788,19 +886,19 @@ function M.clear_history()
if state.output_buf and vim.api.nvim_buf_is_valid(state.output_buf) then
local header = {
"╔═══════════════════════════════════",
"🤖 CODETYPER ASK ",
"╠═══════════════════════════════════",
"║ Ask about code or concepts ",
" ",
"💡 Keymaps: ",
"@ → attach file ",
"║ C-Enter → send ",
"║ C-nnew chat ",
"C-f → add current file",
"C-h/j/k/l → navigate ",
"║ q → close │ K/J → jump ",
"╚═══════════════════════════════════",
"╔═════════════════════════════════╗",
" [ASK MODE] Q&A Chat",
"╠═════════════════════════════════╣",
"║ Ask about code or concepts ║",
"║ ║",
"@ → attach file",
"C-Enter → send",
"║ C-n → new chat",
"║ C-fadd current file",
"L → toggle LLM logs ",
":CoderType → switch mode",
"║ q → close │ K/J → jump ║",
"╚═════════════════════════════════╝",
"",
}
vim.bo[state.output_buf].modifiable = true
@@ -846,6 +944,12 @@ function M.copy_last_response()
end
utils.notify("No response to copy", vim.log.levels.WARN)
end
--- Show chat mode switcher modal
function M.show_chat_switcher()
local switcher = require("codetyper.chat_switcher")
switcher.show()
end
--- Check if ask panel is open (validates window state)
---@return boolean
function M.is_open()
@@ -877,4 +981,18 @@ function M.get_history()
return state.history
end
--- Toggle LLM log visibility in chat
---@return boolean New state
function M.toggle_logs()
state.show_logs = not state.show_logs
utils.notify("LLM logs " .. (state.show_logs and "enabled" or "disabled"))
return state.show_logs
end
--- Check if logs are enabled
---@return boolean
function M.logs_enabled()
return state.show_logs
end
return M

View File

@@ -15,6 +15,9 @@ local TREE_UPDATE_DEBOUNCE_MS = 1000 -- 1 second debounce
---@type table<string, boolean>
local processed_prompts = {}
--- Track if we're currently asking for preferences
local asking_preference = false
--- Generate a unique key for a prompt
---@param bufnr number Buffer number
---@param prompt table Prompt object
@@ -40,19 +43,61 @@ end
function M.setup()
local group = vim.api.nvim_create_augroup(AUGROUP, { clear = true })
-- Auto-save coder file when leaving insert mode
-- Auto-check for closed prompts when leaving insert mode (works on ALL files)
vim.api.nvim_create_autocmd("InsertLeave", {
group = group,
pattern = "*.coder.*",
pattern = "*",
callback = function()
-- Auto-save the coder file
if vim.bo.modified then
-- Skip special buffers
local buftype = vim.bo.buftype
if buftype ~= "" then
return
end
-- Auto-save coder files only
local filepath = vim.fn.expand("%:p")
if utils.is_coder_file(filepath) and vim.bo.modified then
vim.cmd("silent! write")
end
-- Check for closed prompts and auto-process
M.check_for_closed_prompt()
-- Check for closed prompts and auto-process (respects preferences)
M.check_for_closed_prompt_with_preference()
end,
desc = "Auto-save and check for closed prompt tags",
desc = "Check for closed prompt tags on InsertLeave",
})
-- Auto-process prompts when entering normal mode (works on ALL files)
vim.api.nvim_create_autocmd("ModeChanged", {
group = group,
pattern = "*:n",
callback = function()
-- Skip special buffers
local buftype = vim.bo.buftype
if buftype ~= "" then
return
end
-- Slight delay to let buffer settle
vim.defer_fn(function()
M.check_all_prompts_with_preference()
end, 50)
end,
desc = "Auto-process closed prompts when entering normal mode",
})
-- Also check on CursorHold as backup (works on ALL files)
vim.api.nvim_create_autocmd("CursorHold", {
group = group,
pattern = "*",
callback = function()
-- Skip special buffers
local buftype = vim.bo.buftype
if buftype ~= "" then
return
end
local mode = vim.api.nvim_get_mode().mode
if mode == "n" then
M.check_all_prompts_with_preference()
end
end,
desc = "Auto-process closed prompts when idle in normal mode",
})
-- Auto-set filetype for coder files based on extension
@@ -140,6 +185,19 @@ function M.setup()
end,
desc = "Update tree.log on directory change",
})
-- Auto-index: Create/open coder companion file when opening source files
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
pattern = "*",
callback = function(ev)
-- Delay to ensure buffer is fully loaded
vim.defer_fn(function()
M.auto_index_file(ev.buf)
end, 100)
end,
desc = "Auto-index source files with coder companion",
})
end
--- Get config with fallback defaults
@@ -159,12 +217,59 @@ local function get_config_safe()
return config
end
--- Check if the buffer has a newly closed prompt and auto-process
--- Read attached files from prompt content
---@param prompt_content string Prompt content
---@param base_path string Base path to resolve relative file paths
---@return table[] attached_files List of {path, content} tables
local function read_attached_files(prompt_content, base_path)
local parser = require("codetyper.parser")
local file_refs = parser.extract_file_references(prompt_content)
local attached = {}
local cwd = vim.fn.getcwd()
local base_dir = vim.fn.fnamemodify(base_path, ":h")
for _, ref in ipairs(file_refs) do
local file_path = nil
-- Try resolving relative to cwd first
local cwd_path = cwd .. "/" .. ref
if utils.file_exists(cwd_path) then
file_path = cwd_path
else
-- Try resolving relative to base file directory
local rel_path = base_dir .. "/" .. ref
if utils.file_exists(rel_path) then
file_path = rel_path
end
end
if file_path then
local content = utils.read_file(file_path)
if content then
table.insert(attached, {
path = ref,
full_path = file_path,
content = content,
})
end
end
end
return attached
end
--- Check if the buffer has a newly closed prompt and auto-process (works on ANY file)
function M.check_for_closed_prompt()
local config = get_config_safe()
local parser = require("codetyper.parser")
local bufnr = vim.api.nvim_get_current_buf()
local current_file = vim.fn.expand("%:p")
-- Skip if no file
if current_file == "" then
return
end
-- Get current line
local cursor = vim.api.nvim_win_get_cursor(0)
@@ -193,15 +298,372 @@ function M.check_for_closed_prompt()
-- Mark as processed
processed_prompts[prompt_key] = true
-- Auto-process the prompt (no confirmation needed)
utils.notify("Processing prompt...", vim.log.levels.INFO)
vim.schedule(function()
vim.cmd("CoderProcess")
end)
-- Check if scheduler is enabled
local codetyper = require("codetyper")
local ct_config = codetyper.get_config()
local scheduler_enabled = ct_config and ct_config.scheduler and ct_config.scheduler.enabled
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")
-- 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
if utils.is_coder_file(current_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)
local target_bufnr = vim.fn.bufnr(target_path)
if target_bufnr == -1 then
target_bufnr = bufnr
end
local scope = nil
local scope_text = nil
local scope_range = nil
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
-- 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
if 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
-- Determine priority based on intent
local priority = 2 -- Normal
if intent.type == "fix" or intent.type == "complete" then
priority = 1 -- High priority for fixes and completions
elseif intent.type == "test" or intent.type == "document" then
priority = 3 -- Lower priority for tests and docs
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)
else
-- Legacy: direct processing
utils.notify("Processing prompt...", vim.log.levels.INFO)
vim.schedule(function()
vim.cmd("CoderProcess")
end)
end
end
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")
local bufnr = vim.api.nvim_get_current_buf()
local current_file = vim.fn.expand("%:p")
-- Skip if no file
if current_file == "" then
return
end
-- Find all prompts in buffer
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
return
end
-- Check if scheduler is enabled
local codetyper = require("codetyper")
local ct_config = codetyper.get_config()
local scheduler_enabled = ct_config and ct_config.scheduler and ct_config.scheduler.enabled
if not scheduler_enabled then
return
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
if utils.is_coder_file(current_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)
local target_bufnr = vim.fn.bufnr(target_path)
if target_bufnr == -1 then
target_bufnr = bufnr -- Use current buffer if target not loaded
end
local scope = nil
local scope_text = nil
local scope_range = nil
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
-- 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
if 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
-- 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
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 parser = require("codetyper.parser")
-- First check if there are any prompts to process
local bufnr = vim.api.nvim_get_current_buf()
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
return
end
-- Check user preference
local auto_process = preferences.is_auto_process_enabled()
if auto_process == nil then
-- Not yet decided - ask the user (but only once per session)
if not asking_preference then
asking_preference = true
preferences.ask_auto_process_preference(function(enabled)
asking_preference = false
if enabled then
-- User chose automatic - process now
M.check_for_closed_prompt()
else
-- User chose manual - show hint
utils.notify("Use :CoderProcess to process prompt tags manually", vim.log.levels.INFO)
end
end)
end
return
end
if auto_process then
-- Automatic mode - process prompts
M.check_for_closed_prompt()
end
-- Manual mode - do nothing, user will run :CoderProcess
end
--- Check all prompts with preference check
function M.check_all_prompts_with_preference()
local preferences = require("codetyper.preferences")
local parser = require("codetyper.parser")
-- First check if there are any prompts to process
local bufnr = vim.api.nvim_get_current_buf()
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
return
end
-- Check if any prompts are unprocessed
local has_unprocessed = false
for _, prompt in ipairs(prompts) do
local prompt_key = get_prompt_key(bufnr, prompt)
if not processed_prompts[prompt_key] then
has_unprocessed = true
break
end
end
if not has_unprocessed then
return
end
-- Check user preference
local auto_process = preferences.is_auto_process_enabled()
if auto_process == nil then
-- Not yet decided - ask the user (but only once per session)
if not asking_preference then
asking_preference = true
preferences.ask_auto_process_preference(function(enabled)
asking_preference = false
if enabled then
-- User chose automatic - process now
M.check_all_prompts()
else
-- User chose manual - show hint
utils.notify("Use :CoderProcess to process prompt tags manually", vim.log.levels.INFO)
end
end)
end
return
end
if auto_process then
-- Automatic mode - process prompts
M.check_all_prompts()
end
-- Manual mode - do nothing, user will run :CoderProcess
end
--- Reset processed prompts for a buffer (useful for re-processing)
---@param bufnr? number Buffer number (default: current)
function M.reset_processed(bufnr)
@@ -268,11 +730,9 @@ function M.auto_open_target_file()
local codetyper = require("codetyper")
local config = codetyper.get_config()
-- Fallback width if config not fully loaded
local width = (config and config.window and config.window.width) or 0.4
if width <= 1 then
width = math.floor(vim.o.columns * width)
end
-- Fallback width if config not fully loaded (percentage, e.g., 25 = 25%)
local width_pct = (config and config.window and config.window.width) or 25
local width = math.ceil(vim.o.columns * (width_pct / 100))
-- Store current coder window
local coder_win = vim.api.nvim_get_current_win()
@@ -362,4 +822,178 @@ function M.clear()
vim.api.nvim_del_augroup_by_name(AUGROUP)
end
--- Track buffers that have been auto-indexed
---@type table<number, boolean>
local auto_indexed_buffers = {}
--- Supported file extensions for auto-indexing
local supported_extensions = {
"ts", "tsx", "js", "jsx", "py", "lua", "go", "rs", "rb",
"java", "c", "cpp", "cs", "json", "yaml", "yml", "md",
"html", "css", "scss", "vue", "svelte", "php", "sh", "zsh",
}
--- Check if extension is supported
---@param ext string File extension
---@return boolean
local function is_supported_extension(ext)
for _, supported in ipairs(supported_extensions) do
if ext == supported then
return true
end
end
return false
end
--- Auto-index a file by creating/opening its coder companion
---@param bufnr number Buffer number
function M.auto_index_file(bufnr)
-- Skip if buffer is invalid
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-- Skip if already indexed
if auto_indexed_buffers[bufnr] then
return
end
-- Get file path
local filepath = vim.api.nvim_buf_get_name(bufnr)
if not filepath or filepath == "" then
return
end
-- Skip coder files
if utils.is_coder_file(filepath) then
return
end
-- Skip special buffers
local buftype = vim.bo[bufnr].buftype
if buftype ~= "" then
return
end
-- Skip unsupported file types
local ext = vim.fn.fnamemodify(filepath, ":e")
if ext == "" or not is_supported_extension(ext) then
return
end
-- Skip if auto_index is disabled in config
local codetyper = require("codetyper")
local config = codetyper.get_config()
if config and config.auto_index == false then
return
end
-- Mark as indexed
auto_indexed_buffers[bufnr] = true
-- Get coder companion path
local coder_path = utils.get_coder_path(filepath)
-- Check if coder file already exists
local coder_exists = utils.file_exists(coder_path)
-- Create coder file with template if it doesn't exist
if not coder_exists then
local filename = vim.fn.fnamemodify(filepath, ":t")
local template = string.format(
[[-- Coder companion for %s
-- Use /@ @/ tags to write pseudo-code prompts
-- Example:
-- /@
-- Add a function that validates user input
-- - Check for empty strings
-- - Validate email format
-- @/
]],
filename
)
utils.write_file(coder_path, template)
end
-- Notify user about the coder companion
local coder_filename = vim.fn.fnamemodify(coder_path, ":t")
if coder_exists then
utils.notify("Coder companion available: " .. coder_filename, vim.log.levels.DEBUG)
else
utils.notify("Created coder companion: " .. coder_filename, vim.log.levels.INFO)
end
end
--- Open the coder companion for the current file
---@param open_split? boolean Whether to open in split view (default: true)
function M.open_coder_companion(open_split)
open_split = open_split ~= false -- Default to true
local filepath = vim.fn.expand("%:p")
if not filepath or filepath == "" then
utils.notify("No file open", vim.log.levels.WARN)
return
end
if utils.is_coder_file(filepath) then
utils.notify("Already in coder file", vim.log.levels.INFO)
return
end
local coder_path = utils.get_coder_path(filepath)
-- Create if it doesn't exist
if not utils.file_exists(coder_path) then
local filename = vim.fn.fnamemodify(filepath, ":t")
local ext = vim.fn.fnamemodify(filepath, ":e")
local comment_prefix = "--"
if vim.tbl_contains({ "js", "jsx", "ts", "tsx", "java", "c", "cpp", "cs", "go", "rs", "php" }, ext) then
comment_prefix = "//"
elseif vim.tbl_contains({ "py", "sh", "zsh", "yaml", "yml" }, ext) then
comment_prefix = "#"
elseif vim.tbl_contains({ "html", "md" }, ext) then
comment_prefix = "<!--"
end
local close_comment = comment_prefix == "<!--" and " -->" or ""
local template = string.format(
[[%s Coder companion for %s%s
%s Use /@ @/ tags to write pseudo-code prompts%s
%s Example:%s
%s /@%s
%s Add a function that validates user input%s
%s - Check for empty strings%s
%s - Validate email format%s
%s @/%s
]],
comment_prefix, filename, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment
)
utils.write_file(coder_path, template)
end
if open_split then
-- Use the window module to open split view
local window = require("codetyper.window")
window.open_split(coder_path, filepath)
else
-- Just open the coder file
vim.cmd("edit " .. vim.fn.fnameescape(coder_path))
end
end
--- Clear auto-indexed tracking for a buffer
---@param bufnr number Buffer number
function M.clear_auto_indexed(bufnr)
auto_indexed_buffers[bufnr] = nil
end
return M

View File

@@ -0,0 +1,44 @@
---@mod codetyper.chat_switcher Modal picker to switch between Ask and Agent modes
local M = {}
--- Show modal to switch between chat modes
function M.show()
local items = {
{ label = "Ask", desc = "Q&A mode - ask questions about code", mode = "ask" },
{ label = "Agent", desc = "Agent mode - can read/edit files", mode = "agent" },
}
vim.ui.select(items, {
prompt = "Select Chat Mode:",
format_item = function(item)
return item.label .. " - " .. item.desc
end,
}, function(choice)
if not choice then
return
end
-- Close current panel first
local ask = require("codetyper.ask")
local agent_ui = require("codetyper.agent.ui")
if ask.is_open() then
ask.close()
end
if agent_ui.is_open() then
agent_ui.close()
end
-- Open selected mode
vim.schedule(function()
if choice.mode == "ask" then
ask.open()
elseif choice.mode == "agent" then
agent_ui.open()
end
end)
end)
end
return M

View File

@@ -252,6 +252,101 @@ local function cmd_ask_clear()
ask.clear_history()
end
--- Open agent panel
local function cmd_agent()
local agent_ui = require("codetyper.agent.ui")
agent_ui.open()
end
--- Close agent panel
local function cmd_agent_close()
local agent_ui = require("codetyper.agent.ui")
agent_ui.close()
end
--- Toggle agent panel
local function cmd_agent_toggle()
local agent_ui = require("codetyper.agent.ui")
agent_ui.toggle()
end
--- Stop running agent
local function cmd_agent_stop()
local agent = require("codetyper.agent")
if agent.is_running() then
agent.stop()
utils.notify("Agent stopped")
else
utils.notify("No agent running", vim.log.levels.INFO)
end
end
--- Show chat type switcher modal (Ask/Agent)
local function cmd_type_toggle()
local switcher = require("codetyper.chat_switcher")
switcher.show()
end
--- Toggle logs panel
local function cmd_logs_toggle()
local logs_panel = require("codetyper.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 parser = require("codetyper.parser")
local status = scheduler.status()
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
local lines = {
"Scheduler Status",
"================",
"",
"Running: " .. (status.running and "yes" or "NO"),
"Paused: " .. (status.paused and "yes" or "no"),
"Active Workers: " .. status.active_workers,
"",
"Queue Stats:",
" Pending: " .. status.queue_stats.pending,
" Processing: " .. status.queue_stats.processing,
" Completed: " .. status.queue_stats.completed,
" Cancelled: " .. status.queue_stats.cancelled,
"",
}
-- Check current buffer for prompts
if filepath ~= "" then
local prompts = parser.find_prompts_in_buffer(bufnr)
table.insert(lines, "Current Buffer: " .. vim.fn.fnamemodify(filepath, ":t"))
table.insert(lines, " Prompts found: " .. #prompts)
for i, p in ipairs(prompts) do
local preview = p.content:sub(1, 30):gsub("\n", " ")
table.insert(lines, string.format(" %d. Line %d: %s...", i, p.start_line, preview))
end
end
utils.notify(table.concat(lines, "\n"))
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")
-- Open logs panel to show progress
logs_panel.open()
-- Check all prompts in current buffer
autocmds.check_all_prompts()
utils.notify("Triggered queue processing for current buffer")
end
--- Switch focus between coder and target windows
local function cmd_focus()
if not window.is_open() then
@@ -272,6 +367,8 @@ end
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 bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
@@ -289,6 +386,10 @@ local function cmd_transform()
return
end
-- Open the logs panel to show generation progress
logs_panel.open()
logs.info("Transform started: " .. #prompts .. " prompt(s)")
utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO)
-- Build context for this file
@@ -320,11 +421,13 @@ local function cmd_transform()
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
@@ -376,10 +479,9 @@ local function cmd_transform()
completed = completed + 1
if completed + errors >= pending then
utils.notify(
"Transform complete: " .. completed .. " succeeded, " .. errors .. " failed",
errors > 0 and vim.log.levels.WARN or vim.log.levels.INFO
)
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
@@ -393,6 +495,8 @@ end
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 bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
@@ -418,6 +522,10 @@ local function cmd_transform_range(start_line, end_line)
return
end
-- Open the logs panel to show generation progress
logs_panel.open()
logs.info("Transform selection: " .. #prompts .. " prompt(s)")
utils.notify("Found " .. #prompts .. " prompt(s) in selection to transform...", vim.log.levels.INFO)
-- Build context for this file
@@ -444,10 +552,12 @@ local function cmd_transform_range(start_line, end_line)
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
@@ -490,10 +600,9 @@ local function cmd_transform_range(start_line, end_line)
completed = completed + 1
if completed + errors >= pending then
utils.notify(
"Transform complete: " .. completed .. " succeeded, " .. errors .. " failed",
errors > 0 and vim.log.levels.WARN or vim.log.levels.INFO
)
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
@@ -513,6 +622,8 @@ end
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 bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
@@ -530,9 +641,14 @@ local function cmd_transform_at_cursor()
return
end
-- Open the logs panel to show generation progress
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"
@@ -546,6 +662,7 @@ local function cmd_transform_at_cursor()
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
@@ -587,6 +704,7 @@ local function cmd_transform_at_cursor()
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
@@ -615,6 +733,37 @@ local function coder_cmd(args)
gitignore = cmd_gitignore,
transform = cmd_transform,
["transform-cursor"] = cmd_transform_at_cursor,
agent = cmd_agent,
["agent-close"] = cmd_agent_close,
["agent-toggle"] = cmd_agent_toggle,
["agent-stop"] = cmd_agent_stop,
["type-toggle"] = cmd_type_toggle,
["logs-toggle"] = cmd_logs_toggle,
["queue-status"] = cmd_queue_status,
["queue-process"] = cmd_queue_process,
["auto-toggle"] = function()
local preferences = require("codetyper.preferences")
preferences.toggle_auto_process()
end,
["auto-set"] = function(args)
local preferences = require("codetyper.preferences")
local arg = (args[1] or ""):lower()
if arg == "auto" or arg == "automatic" or arg == "on" then
preferences.set_auto_process(true)
utils.notify("Set to automatic mode", vim.log.levels.INFO)
elseif arg == "manual" or arg == "off" then
preferences.set_auto_process(false)
utils.notify("Set to manual mode", vim.log.levels.INFO)
else
local auto = preferences.is_auto_process_enabled()
if auto == nil then
utils.notify("Mode not set yet (will ask on first prompt)", vim.log.levels.INFO)
else
local mode = auto and "automatic" or "manual"
utils.notify("Currently in " .. mode .. " mode", vim.log.levels.INFO)
end
end
end,
}
local cmd_fn = commands[subcommand]
@@ -635,6 +784,10 @@ function M.setup()
"tree", "tree-view", "reset", "gitignore",
"ask", "ask-close", "ask-toggle", "ask-clear",
"transform", "transform-cursor",
"agent", "agent-close", "agent-toggle", "agent-stop",
"type-toggle", "logs-toggle",
"queue-status", "queue-process",
"auto-toggle", "auto-set",
}
end,
desc = "Codetyper.nvim commands",
@@ -693,6 +846,77 @@ function M.setup()
cmd_transform_range(start_line, end_line)
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("CoderAgentToggle", function()
cmd_agent_toggle()
end, { desc = "Toggle Agent panel" })
vim.api.nvim_create_user_command("CoderAgentStop", function()
cmd_agent_stop()
end, { desc = "Stop running agent" })
-- Chat type switcher command
vim.api.nvim_create_user_command("CoderType", function()
cmd_type_toggle()
end, { desc = "Show Ask/Agent mode switcher" })
-- Logs panel command
vim.api.nvim_create_user_command("CoderLogs", function()
cmd_logs_toggle()
end, { desc = "Toggle logs panel" })
-- Index command - open coder companion for current file
vim.api.nvim_create_user_command("CoderIndex", function()
local autocmds = require("codetyper.autocmds")
autocmds.open_coder_companion()
end, { desc = "Open coder companion for current file" })
-- Queue commands
vim.api.nvim_create_user_command("CoderQueueStatus", function()
cmd_queue_status()
end, { desc = "Show scheduler and queue status" })
vim.api.nvim_create_user_command("CoderQueueProcess", function()
cmd_queue_process()
end, { desc = "Manually trigger queue processing" })
-- Preferences commands
vim.api.nvim_create_user_command("CoderAutoToggle", function()
local preferences = require("codetyper.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 arg = opts.args:lower()
if arg == "auto" or arg == "automatic" or arg == "on" then
preferences.set_auto_process(true)
vim.notify("Codetyper: Set to automatic mode", vim.log.levels.INFO)
elseif arg == "manual" or arg == "off" then
preferences.set_auto_process(false)
vim.notify("Codetyper: Set to manual mode", vim.log.levels.INFO)
else
-- Show current mode
local auto = preferences.is_auto_process_enabled()
if auto == nil then
vim.notify("Codetyper: Mode not set yet (will ask on first prompt)", vim.log.levels.INFO)
else
local mode = auto and "automatic" or "manual"
vim.notify("Codetyper: Currently in " .. mode .. " mode", vim.log.levels.INFO)
end
end
end, {
desc = "Set prompt processing mode (auto/manual)",
nargs = "?",
complete = function()
return { "auto", "manual" }
end,
})
-- Setup default keymaps
M.setup_keymaps()
end
@@ -712,9 +936,21 @@ function M.setup_keymaps()
})
-- Normal mode: transform all tags in file
vim.keymap.set("n", "<leader>ctT", "<cmd>CoderTransform<CR>", {
silent = true,
desc = "Coder: Transform all tags in file"
vim.keymap.set("n", "<leader>ctT", "<cmd>CoderTransform<CR>", {
silent = true,
desc = "Coder: Transform all tags in file"
})
-- Agent keymaps
vim.keymap.set("n", "<leader>ca", "<cmd>CoderAgentToggle<CR>", {
silent = true,
desc = "Coder: Toggle Agent panel"
})
-- Index keymap - open coder companion
vim.keymap.set("n", "<leader>ci", "<cmd>CoderIndex<CR>", {
silent = true,
desc = "Coder: Open coder companion for file"
})
end

View File

@@ -0,0 +1,192 @@
---@mod codetyper.completion Insert mode completion for file references
---
--- Provides completion for @filename inside /@ @/ tags.
local M = {}
local parser = require("codetyper.parser")
local utils = require("codetyper.utils")
--- Get list of files for completion
---@param prefix string Prefix to filter files
---@return table[] List of completion items
local function get_file_completions(prefix)
local cwd = vim.fn.getcwd()
local current_file = vim.fn.expand("%:p")
local current_dir = vim.fn.fnamemodify(current_file, ":h")
local files = {}
-- Use vim.fn.glob to find files matching the prefix
local pattern = prefix .. "*"
-- Determine base directory - use current file's directory if outside cwd
local base_dir = cwd
if current_dir ~= "" and not current_dir:find(cwd, 1, true) then
-- File is outside project, use its directory as base
base_dir = current_dir
end
-- Search in base directory
local matches = vim.fn.glob(base_dir .. "/" .. pattern, false, true)
-- Search with ** for all subdirectories
local deep_matches = vim.fn.glob(base_dir .. "/**/" .. pattern, false, true)
for _, m in ipairs(deep_matches) do
table.insert(matches, m)
end
-- Also search in cwd if different from base_dir
if base_dir ~= cwd then
local cwd_matches = vim.fn.glob(cwd .. "/" .. pattern, false, true)
for _, m in ipairs(cwd_matches) do
table.insert(matches, m)
end
local cwd_deep = vim.fn.glob(cwd .. "/**/" .. pattern, false, true)
for _, m in ipairs(cwd_deep) do
table.insert(matches, m)
end
end
-- Also search specific directories if prefix doesn't have path
if not prefix:find("/") then
local search_dirs = { "src", "lib", "lua", "app", "components", "utils", "tests" }
for _, dir in ipairs(search_dirs) do
local dir_path = base_dir .. "/" .. dir
if vim.fn.isdirectory(dir_path) == 1 then
local dir_matches = vim.fn.glob(dir_path .. "/**/" .. pattern, false, true)
for _, m in ipairs(dir_matches) do
table.insert(matches, m)
end
end
end
end
-- Convert to relative paths and deduplicate
local seen = {}
for _, match in ipairs(matches) do
-- Convert to relative path based on which base it came from
local rel_path
if match:find(base_dir, 1, true) == 1 then
rel_path = match:sub(#base_dir + 2)
elseif match:find(cwd, 1, true) == 1 then
rel_path = match:sub(#cwd + 2)
else
rel_path = vim.fn.fnamemodify(match, ":t") -- Just filename if can't make relative
end
-- Skip directories, coder files, and hidden/generated files
if vim.fn.isdirectory(match) == 0
and not utils.is_coder_file(match)
and not rel_path:match("^%.")
and not rel_path:match("node_modules")
and not rel_path:match("%.git/")
and not rel_path:match("dist/")
and not rel_path:match("build/")
and not seen[rel_path]
then
seen[rel_path] = true
table.insert(files, {
word = rel_path,
abbr = rel_path,
kind = "File",
menu = "[ref]",
})
end
end
-- Sort by length (shorter paths first)
table.sort(files, function(a, b)
return #a.word < #b.word
end)
-- Limit results
local result = {}
for i = 1, math.min(#files, 15) do
result[i] = files[i]
end
return result
end
--- Show file completion popup
function M.show_file_completion()
-- Check if we're in an open prompt tag
local is_inside = parser.is_cursor_in_open_tag()
if not is_inside then
return false
end
-- Get the prefix being typed
local prefix = parser.get_file_ref_prefix()
if prefix == nil then
return false
end
-- Get completions
local items = get_file_completions(prefix)
if #items == 0 then
-- Try with empty prefix to show all files
items = get_file_completions("")
end
if #items > 0 then
-- Calculate start column (position right after @)
local cursor = vim.api.nvim_win_get_cursor(0)
local col = cursor[2] - #prefix + 1 -- 1-indexed for complete()
-- Show completion popup
vim.fn.complete(col, items)
return true
end
return false
end
--- Setup completion for file references (works on ALL files)
function M.setup()
local group = vim.api.nvim_create_augroup("CoderCompletion", { clear = true })
-- Trigger completion on @ in insert mode (works on ALL files)
vim.api.nvim_create_autocmd("InsertCharPre", {
group = group,
pattern = "*",
callback = function()
-- Skip special buffers
if vim.bo.buftype ~= "" then
return
end
if vim.v.char == "@" then
-- Schedule completion popup after the @ is inserted
vim.schedule(function()
-- Check we're in an open tag
local is_inside = parser.is_cursor_in_open_tag()
if not is_inside then
return
end
-- Check we're not typing @/ (closing tag)
local cursor = vim.api.nvim_win_get_cursor(0)
local line = vim.api.nvim_get_current_line()
local next_char = line:sub(cursor[2] + 2, cursor[2] + 2)
if next_char == "/" then
return
end
-- Show file completion
M.show_file_completion()
end)
end
end,
desc = "Trigger file completion on @ inside prompt tags",
})
-- Also allow manual trigger with <C-x><C-f> style keybinding in insert mode
vim.keymap.set("i", "<C-x>@", function()
M.show_file_completion()
end, { silent = true, desc = "Coder: Complete file reference" })
end
return M

View File

@@ -5,7 +5,7 @@ local M = {}
---@type CoderConfig
local defaults = {
llm = {
provider = "ollama",
provider = "ollama", -- Options: "claude", "ollama", "openai", "gemini", "copilot"
claude = {
api_key = nil, -- Will use ANTHROPIC_API_KEY env var if nil
model = "claude-sonnet-4-20250514",
@@ -14,9 +14,21 @@ local defaults = {
host = "http://localhost:11434",
model = "deepseek-coder:6.7b",
},
openai = {
api_key = nil, -- Will use OPENAI_API_KEY env var if nil
model = "gpt-4o",
endpoint = nil, -- Custom endpoint (Azure, OpenRouter, etc.)
},
gemini = {
api_key = nil, -- Will use GEMINI_API_KEY env var if nil
model = "gemini-2.0-flash",
},
copilot = {
model = "gpt-4o", -- Uses GitHub Copilot authentication
},
},
window = {
width = 0.25, -- 25% of screen width (1/4)
width = 25, -- 25% of screen width (1/4)
position = "left",
border = "rounded",
},
@@ -27,6 +39,15 @@ local defaults = {
},
auto_gitignore = true,
auto_open_ask = true, -- Auto-open Ask panel on startup
auto_index = false, -- Auto-create coder companion files on file open
scheduler = {
enabled = true, -- Enable event-driven scheduler
ollama_scout = true, -- Use Ollama as fast local scout for first attempt
escalation_threshold = 0.7, -- Below this confidence, escalate to remote LLM
max_concurrent = 2, -- Maximum concurrent workers
completion_delay_ms = 100, -- Wait after completion popup closes
apply_delay_ms = 5000, -- Wait before removing tags and applying code (ms)
},
}
--- Deep merge two tables
@@ -67,16 +88,38 @@ function M.validate(config)
return false, "Missing LLM configuration"
end
if config.llm.provider ~= "claude" and config.llm.provider ~= "ollama" then
return false, "Invalid LLM provider. Must be 'claude' or 'ollama'"
local valid_providers = { "claude", "ollama", "openai", "gemini", "copilot" }
local is_valid_provider = false
for _, p in ipairs(valid_providers) do
if config.llm.provider == p then
is_valid_provider = true
break
end
end
if not is_valid_provider then
return false, "Invalid LLM provider. Must be one of: " .. table.concat(valid_providers, ", ")
end
-- Validate provider-specific configuration
if config.llm.provider == "claude" then
local api_key = config.llm.claude.api_key or vim.env.ANTHROPIC_API_KEY
if not api_key or api_key == "" then
return false, "Claude API key not configured. Set llm.claude.api_key or ANTHROPIC_API_KEY env var"
end
elseif config.llm.provider == "openai" then
local api_key = config.llm.openai.api_key or vim.env.OPENAI_API_KEY
if not api_key or api_key == "" then
return false, "OpenAI API key not configured. Set llm.openai.api_key or OPENAI_API_KEY env var"
end
elseif config.llm.provider == "gemini" then
local api_key = config.llm.gemini.api_key or vim.env.GEMINI_API_KEY
if not api_key or api_key == "" then
return false, "Gemini API key not configured. Set llm.gemini.api_key or GEMINI_API_KEY env var"
end
end
-- Note: copilot uses OAuth from copilot.lua/copilot.vim, validated at runtime
-- Note: ollama doesn't require API key, just host configuration
return true
end

View File

@@ -1,8 +1,10 @@
---@mod codetyper Codetyper.nvim - AI-powered coding partner
---@brief [[
--- Codetyper.nvim is a Neovim plugin that acts as your coding partner.
--- It uses LLM APIs (Claude, Ollama) to help you write code faster
--- using special `.coder.*` files and inline prompt tags.
--- It uses LLM APIs (Claude, OpenAI, Gemini, Copilot, Ollama) to help you
--- write code faster using special `.coder.*` files and inline prompt tags.
--- Features an event-driven scheduler with confidence scoring and
--- completion-aware injection timing.
---@brief ]]
local M = {}
@@ -28,6 +30,8 @@ function M.setup(opts)
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")
-- Register commands
commands.setup()
@@ -35,12 +39,24 @@ function M.setup(opts)
-- Setup autocommands
autocmds.setup()
-- Setup file reference completion
completion.setup()
-- Setup logs panel (handles VimLeavePre cleanup)
logs_panel.setup()
-- Ensure .gitignore has coder files excluded
gitignore.ensure_ignored()
-- Initialize tree logging (creates .coder folder and initial tree.log)
tree.setup()
-- Start the event-driven scheduler if enabled
if M.config.scheduler and M.config.scheduler.enabled then
local scheduler = require("codetyper.agent.scheduler")
scheduler.start(M.config.scheduler)
end
M._initialized = true
-- Auto-open Ask panel after a short delay (to let UI settle)

View File

@@ -11,19 +11,19 @@ local API_URL = "https://api.anthropic.com/v1/messages"
--- Get API key from config or environment
---@return string|nil API key
local function get_api_key()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.claude.api_key or vim.env.ANTHROPIC_API_KEY
return config.llm.claude.api_key or vim.env.ANTHROPIC_API_KEY
end
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.claude.model
return config.llm.claude.model
end
--- Build request body for Claude API
@@ -31,95 +31,103 @@ end
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
local system_prompt = llm.build_system_prompt(context)
return {
model = get_model(),
max_tokens = 4096,
system = system_prompt,
messages = {
{
role = "user",
content = prompt,
},
},
}
return {
model = get_model(),
max_tokens = 4096,
system = system_prompt,
messages = {
{
role = "user",
content = prompt,
},
},
}
end
--- Make HTTP request to Claude API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil) Callback function
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
local function make_request(body, callback)
local api_key = get_api_key()
if not api_key then
callback(nil, "Claude API key not configured")
return
end
local api_key = get_api_key()
if not api_key then
callback(nil, "Claude API key not configured", nil)
return
end
local json_body = vim.json.encode(body)
local json_body = vim.json.encode(body)
-- Use curl for HTTP request (plenary.curl alternative)
local cmd = {
"curl",
"-s",
"-X", "POST",
API_URL,
"-H", "Content-Type: application/json",
"-H", "x-api-key: " .. api_key,
"-H", "anthropic-version: 2023-06-01",
"-d", json_body,
}
-- Use curl for HTTP request (plenary.curl alternative)
local cmd = {
"curl",
"-s",
"-X",
"POST",
API_URL,
"-H",
"Content-Type: application/json",
"-H",
"x-api-key: " .. api_key,
"-H",
"anthropic-version: 2023-06-01",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Claude response")
end)
return
end
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Claude response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Claude API error")
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Claude API error", nil)
end)
return
end
if response.content and response.content[1] and response.content[1].text then
local code = llm.extract_code(response.content[1].text)
vim.schedule(function()
callback(code, nil)
end)
else
vim.schedule(function()
callback(nil, "No content in Claude response")
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Claude API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Claude API request failed with code: " .. code)
end)
end
end,
})
-- Extract usage info
local usage = response.usage or {}
if response.content and response.content[1] and response.content[1].text then
local code = llm.extract_code(response.content[1].text)
vim.schedule(function()
callback(code, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No content in Claude response", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Claude API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Claude API request failed with code: " .. code, nil)
end)
end
end,
})
end
--- Generate code using Claude API
@@ -127,28 +135,230 @@ end
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
utils.notify("Sending request to Claude...", vim.log.levels.INFO)
local logs = require("codetyper.agent.logs")
local model = get_model()
local body = build_request_body(prompt, context)
make_request(body, function(response, err)
if err then
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
-- Log the request
logs.request("claude", model)
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Claude API...")
utils.notify("Sending request to Claude...", vim.log.levels.INFO)
make_request(body, function(response, err, usage)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
-- Log token usage
if usage then
logs.response(usage.input_tokens or 0, usage.output_tokens or 0, "end_turn")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end
--- Check if Claude is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
local api_key = get_api_key()
if not api_key or api_key == "" then
return false, "Claude API key not configured"
end
return true
local api_key = get_api_key()
if not api_key or api_key == "" then
return false, "Claude API key not configured"
end
return true
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@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 model = get_model()
-- Log the request
logs.request("claude", model)
logs.thinking("Preparing agent request...")
local api_key = get_api_key()
if not api_key then
logs.error("Claude API key not configured")
callback(nil, "Claude API key not configured")
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
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
-- Build request body with tools
local body = {
model = get_model(),
max_tokens = 4096,
system = system_prompt,
messages = M.format_messages_for_claude(messages),
tools = tools_module.to_claude_format(),
}
local json_body = vim.json.encode(body)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(json_body)
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Claude API...")
local cmd = {
"curl",
"-s",
"-X",
"POST",
API_URL,
"-H",
"Content-Type: application/json",
"-H",
"x-api-key: " .. api_key,
"-H",
"anthropic-version: 2023-06-01",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
logs.error("Failed to parse Claude response")
callback(nil, "Failed to parse Claude response")
end)
return
end
if response.error then
vim.schedule(function()
logs.error(response.error.message or "Claude API error")
callback(nil, response.error.message or "Claude API error")
end)
return
end
-- Log token usage from response
if response.usage then
logs.response(response.usage.input_tokens or 0, response.usage.output_tokens or 0, response.stop_reason)
end
-- Log what's in the response
if response.content then
for _, block in ipairs(response.content) do
if block.type == "text" then
logs.thinking("Response contains text")
elseif block.type == "tool_use" then
logs.thinking("Tool call: " .. block.name)
end
end
end
-- Return raw response for parser to handle
vim.schedule(function()
callback(response, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
logs.error("Claude API request failed: " .. table.concat(data, "\n"))
callback(nil, "Claude API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
logs.error("Claude API request failed with code: " .. code)
callback(nil, "Claude API request failed with code: " .. code)
end)
end
end,
})
end
--- Format messages for Claude API
---@param messages table[] Internal message format
---@return table[] Claude API message format
function M.format_messages_for_claude(messages)
local formatted = {}
for _, msg in ipairs(messages) do
if msg.role == "user" then
if type(msg.content) == "table" then
-- Tool results
table.insert(formatted, {
role = "user",
content = msg.content,
})
else
table.insert(formatted, {
role = "user",
content = msg.content,
})
end
elseif msg.role == "assistant" then
-- Build content array for assistant messages
local content = {}
-- Add text if present
if msg.content and msg.content ~= "" then
table.insert(content, {
type = "text",
text = msg.content,
})
end
-- Add tool uses if present
if msg.tool_calls then
for _, tool_call in ipairs(msg.tool_calls) do
table.insert(content, {
type = "tool_use",
id = tool_call.id,
name = tool_call.name,
input = tool_call.parameters,
})
end
end
if #content > 0 then
table.insert(formatted, {
role = "assistant",
content = content,
})
end
end
end
return formatted
end
return M

View File

@@ -0,0 +1,501 @@
---@mod codetyper.llm.copilot GitHub Copilot API client for Codetyper.nvim
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
--- Copilot API endpoints
local AUTH_URL = "https://api.github.com/copilot_internal/v2/token"
--- Cached state
---@class CopilotState
---@field oauth_token string|nil
---@field github_token table|nil
M.state = nil
--- Get OAuth token from copilot.lua or copilot.vim config
---@return string|nil OAuth token
local function get_oauth_token()
local xdg_config = vim.fn.expand("$XDG_CONFIG_HOME")
local os_name = vim.loop.os_uname().sysname:lower()
local config_dir
if xdg_config and vim.fn.isdirectory(xdg_config) > 0 then
config_dir = xdg_config
elseif os_name:match("linux") or os_name:match("darwin") then
config_dir = vim.fn.expand("~/.config")
else
config_dir = vim.fn.expand("~/AppData/Local")
end
-- Try hosts.json (copilot.lua) and apps.json (copilot.vim)
local paths = { "hosts.json", "apps.json" }
for _, filename in ipairs(paths) do
local path = config_dir .. "/github-copilot/" .. filename
if vim.fn.filereadable(path) == 1 then
local content = vim.fn.readfile(path)
if content and #content > 0 then
local ok, data = pcall(vim.json.decode, table.concat(content, "\n"))
if ok and data then
for key, value in pairs(data) do
if key:match("github.com") and value.oauth_token then
return value.oauth_token
end
end
end
end
end
end
return nil
end
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.copilot.model
end
--- Refresh GitHub token using OAuth token
---@param callback fun(token: table|nil, error: string|nil)
local function refresh_token(callback)
if not M.state or not M.state.oauth_token then
callback(nil, "No OAuth token available")
return
end
-- Check if current token is still valid
if M.state.github_token and M.state.github_token.expires_at then
if M.state.github_token.expires_at > os.time() then
callback(M.state.github_token, nil)
return
end
end
local cmd = {
"curl",
"-s",
"-X",
"GET",
AUTH_URL,
"-H",
"Authorization: token " .. M.state.oauth_token,
"-H",
"Accept: application/json",
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, token = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse token response")
end)
return
end
if token.error then
vim.schedule(function()
callback(nil, token.error_description or "Token refresh failed")
end)
return
end
M.state.github_token = token
vim.schedule(function()
callback(token, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Token refresh failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Token refresh failed with code: " .. code)
end)
end
end,
})
end
--- Build request headers
---@param token table GitHub token
---@return table Headers
local function build_headers(token)
return {
"Authorization: Bearer " .. token.token,
"Content-Type: application/json",
"User-Agent: GitHubCopilotChat/0.26.7",
"Editor-Version: vscode/1.105.1",
"Editor-Plugin-Version: copilot-chat/0.26.7",
"Copilot-Integration-Id: vscode-chat",
"Openai-Intent: conversation-edits",
}
end
--- Build request body for Copilot API
---@param prompt string User prompt
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
return {
model = get_model(),
messages = {
{ role = "system", content = system_prompt },
{ role = "user", content = prompt },
},
max_tokens = 4096,
temperature = 0.2,
stream = false,
}
end
--- Make HTTP request to Copilot API
---@param token table GitHub token
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil)
local function make_request(token, body, callback)
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com")
.. "/chat/completions"
local json_body = vim.json.encode(body)
local headers = build_headers(token)
local cmd = {
"curl",
"-s",
"-X",
"POST",
endpoint,
}
for _, header in ipairs(headers) do
table.insert(cmd, "-H")
table.insert(cmd, header)
end
table.insert(cmd, "-d")
table.insert(cmd, json_body)
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Copilot response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Copilot API error", nil)
end)
return
end
-- Extract usage info
local usage = response.usage or {}
if response.choices and response.choices[1] and response.choices[1].message then
local code = llm.extract_code(response.choices[1].message.content)
vim.schedule(function()
callback(code, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No content in Copilot response", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Copilot API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Copilot API request failed with code: " .. code, nil)
end)
end
end,
})
end
--- Initialize Copilot state
local function ensure_initialized()
if not M.state then
M.state = {
oauth_token = get_oauth_token(),
github_token = nil,
}
end
end
--- Generate code using Copilot API
---@param prompt string The user's prompt
---@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")
ensure_initialized()
if not M.state.oauth_token then
local err = "Copilot not authenticated. Please set up copilot.lua or copilot.vim first."
logs.error(err)
callback(nil, err)
return
end
local model = get_model()
logs.request("copilot", model)
logs.thinking("Refreshing authentication token...")
refresh_token(function(token, err)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
return
end
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Copilot API...")
utils.notify("Sending request to Copilot...", vim.log.levels.INFO)
make_request(token, body, function(response, request_err, usage)
if request_err then
logs.error(request_err)
utils.notify(request_err, vim.log.levels.ERROR)
callback(nil, request_err)
else
if usage then
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end)
end
--- Check if Copilot is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
ensure_initialized()
if not M.state.oauth_token then
return false, "Copilot not authenticated. Set up copilot.lua or copilot.vim first."
end
return true
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@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")
ensure_initialized()
if not M.state.oauth_token then
local err = "Copilot not authenticated"
logs.error(err)
callback(nil, err)
return
end
local model = get_model()
logs.request("copilot", model)
logs.thinking("Refreshing authentication token...")
refresh_token(function(token, err)
if err then
logs.error(err)
callback(nil, err)
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
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
-- Format messages for Copilot (OpenAI-compatible format)
local copilot_messages = { { role = "system", content = system_prompt } }
for _, msg in ipairs(messages) do
if type(msg.content) == "string" then
table.insert(copilot_messages, { role = msg.role, content = msg.content })
elseif type(msg.content) == "table" then
local text_parts = {}
for _, part in ipairs(msg.content) do
if part.type == "tool_result" then
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
elseif part.type == "text" then
table.insert(text_parts, part.text or "")
end
end
if #text_parts > 0 then
table.insert(copilot_messages, { role = msg.role, content = table.concat(text_parts, "\n") })
end
end
end
local body = {
model = get_model(),
messages = copilot_messages,
max_tokens = 4096,
temperature = 0.3,
stream = false,
tools = tools_module.to_openai_format(),
}
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com")
.. "/chat/completions"
local json_body = vim.json.encode(body)
local prompt_estimate = logs.estimate_tokens(json_body)
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Copilot API...")
local headers = build_headers(token)
local cmd = {
"curl",
"-s",
"-X",
"POST",
endpoint,
}
for _, header in ipairs(headers) do
table.insert(cmd, "-H")
table.insert(cmd, header)
end
table.insert(cmd, "-d")
table.insert(cmd, json_body)
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
logs.error("Failed to parse Copilot response")
callback(nil, "Failed to parse Copilot response")
end)
return
end
if response.error then
vim.schedule(function()
logs.error(response.error.message or "Copilot API error")
callback(nil, response.error.message or "Copilot API error")
end)
return
end
-- Log token usage
if response.usage then
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
end
-- Convert to Claude-like format for parser compatibility
local converted = { content = {} }
if response.choices and response.choices[1] then
local choice = response.choices[1]
if choice.message then
if choice.message.content then
table.insert(converted.content, { type = "text", text = choice.message.content })
logs.thinking("Response contains text")
end
if choice.message.tool_calls then
for _, tc in ipairs(choice.message.tool_calls) do
local args = {}
if tc["function"] and tc["function"].arguments then
local ok_args, parsed = pcall(vim.json.decode, tc["function"].arguments)
if ok_args then
args = parsed
end
end
table.insert(converted.content, {
type = "tool_use",
id = tc.id,
name = tc["function"].name,
input = args,
})
logs.thinking("Tool call: " .. tc["function"].name)
end
end
end
end
vim.schedule(function()
callback(converted, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
logs.error("Copilot API request failed: " .. table.concat(data, "\n"))
callback(nil, "Copilot API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
logs.error("Copilot API request failed with code: " .. code)
callback(nil, "Copilot API request failed with code: " .. code)
end)
end
end,
})
end)
end
return M

View File

@@ -0,0 +1,394 @@
---@mod codetyper.llm.gemini Google Gemini API client for Codetyper.nvim
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
--- Gemini API endpoint
local API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
--- Get API key from config or environment
---@return string|nil API key
local function get_api_key()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.gemini.api_key or vim.env.GEMINI_API_KEY
end
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.gemini.model
end
--- Build request body for Gemini API
---@param prompt string User prompt
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
return {
systemInstruction = {
role = "user",
parts = { { text = system_prompt } },
},
contents = {
{
role = "user",
parts = { { text = prompt } },
},
},
generationConfig = {
temperature = 0.2,
maxOutputTokens = 4096,
},
}
end
--- Make HTTP request to Gemini API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
local function make_request(body, callback)
local api_key = get_api_key()
if not api_key then
callback(nil, "Gemini API key not configured", nil)
return
end
local model = get_model()
local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key
local json_body = vim.json.encode(body)
local cmd = {
"curl",
"-s",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Gemini response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Gemini API error", nil)
end)
return
end
-- Extract usage info
local usage = {}
if response.usageMetadata then
usage.prompt_tokens = response.usageMetadata.promptTokenCount or 0
usage.completion_tokens = response.usageMetadata.candidatesTokenCount or 0
end
if response.candidates and response.candidates[1] then
local candidate = response.candidates[1]
if candidate.content and candidate.content.parts then
local text_parts = {}
for _, part in ipairs(candidate.content.parts) do
if part.text then
table.insert(text_parts, part.text)
end
end
local full_text = table.concat(text_parts, "")
local code = llm.extract_code(full_text)
vim.schedule(function()
callback(code, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No content in Gemini response", nil)
end)
end
else
vim.schedule(function()
callback(nil, "No candidates in Gemini response", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Gemini API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Gemini API request failed with code: " .. code, nil)
end)
end
end,
})
end
--- Generate code using Gemini API
---@param prompt string The user's prompt
---@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 model = get_model()
-- Log the request
logs.request("gemini", model)
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Gemini API...")
utils.notify("Sending request to Gemini...", vim.log.levels.INFO)
make_request(body, function(response, err, usage)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
-- Log token usage
if usage then
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end
--- Check if Gemini is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
local api_key = get_api_key()
if not api_key or api_key == "" then
return false, "Gemini API key not configured"
end
return true
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@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 model = get_model()
logs.request("gemini", model)
logs.thinking("Preparing agent request...")
local api_key = get_api_key()
if not api_key then
logs.error("Gemini API key not configured")
callback(nil, "Gemini API key not configured")
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
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
-- Format messages for Gemini
local gemini_contents = {}
for _, msg in ipairs(messages) do
local role = msg.role == "assistant" and "model" or "user"
local parts = {}
if type(msg.content) == "string" then
table.insert(parts, { text = msg.content })
elseif type(msg.content) == "table" then
for _, part in ipairs(msg.content) do
if part.type == "tool_result" then
table.insert(parts, { text = "[" .. (part.name or "tool") .. " result]: " .. (part.content or "") })
elseif part.type == "text" then
table.insert(parts, { text = part.text or "" })
end
end
end
if #parts > 0 then
table.insert(gemini_contents, { role = role, parts = parts })
end
end
-- Build function declarations for tools
local function_declarations = {}
for _, tool in ipairs(tools_module.definitions) do
local properties = {}
local required = {}
if tool.parameters and tool.parameters.properties then
for name, prop in pairs(tool.parameters.properties) do
properties[name] = {
type = prop.type:upper(),
description = prop.description,
}
end
end
if tool.parameters and tool.parameters.required then
required = tool.parameters.required
end
table.insert(function_declarations, {
name = tool.name,
description = tool.description,
parameters = {
type = "OBJECT",
properties = properties,
required = required,
},
})
end
local body = {
systemInstruction = {
role = "user",
parts = { { text = system_prompt } },
},
contents = gemini_contents,
generationConfig = {
temperature = 0.3,
maxOutputTokens = 4096,
},
tools = {
{ functionDeclarations = function_declarations },
},
}
local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key
local json_body = vim.json.encode(body)
local prompt_estimate = logs.estimate_tokens(json_body)
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Gemini API...")
local cmd = {
"curl",
"-s",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
logs.error("Failed to parse Gemini response")
callback(nil, "Failed to parse Gemini response")
end)
return
end
if response.error then
vim.schedule(function()
logs.error(response.error.message or "Gemini API error")
callback(nil, response.error.message or "Gemini API error")
end)
return
end
-- Log token usage
if response.usageMetadata then
logs.response(
response.usageMetadata.promptTokenCount or 0,
response.usageMetadata.candidatesTokenCount or 0,
"stop"
)
end
-- Convert to Claude-like format for parser compatibility
local converted = { content = {} }
if response.candidates and response.candidates[1] then
local candidate = response.candidates[1]
if candidate.content and candidate.content.parts then
for _, part in ipairs(candidate.content.parts) do
if part.text then
table.insert(converted.content, { type = "text", text = part.text })
logs.thinking("Response contains text")
elseif part.functionCall then
table.insert(converted.content, {
type = "tool_use",
id = vim.fn.sha256(vim.json.encode(part.functionCall)):sub(1, 16),
name = part.functionCall.name,
input = part.functionCall.args or {},
})
logs.thinking("Tool call: " .. part.functionCall.name)
end
end
end
end
vim.schedule(function()
callback(converted, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
logs.error("Gemini API request failed: " .. table.concat(data, "\n"))
callback(nil, "Gemini API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
logs.error("Gemini API request failed with code: " .. code)
callback(nil, "Gemini API request failed with code: " .. code)
end)
end
end,
})
end
return M

View File

@@ -14,6 +14,12 @@ function M.get_client()
return require("codetyper.llm.claude")
elseif config.llm.provider == "ollama" then
return require("codetyper.llm.ollama")
elseif config.llm.provider == "openai" then
return require("codetyper.llm.openai")
elseif config.llm.provider == "gemini" then
return require("codetyper.llm.gemini")
elseif config.llm.provider == "copilot" then
return require("codetyper.llm.copilot")
else
error("Unknown LLM provider: " .. config.llm.provider)
end

View File

@@ -8,19 +8,19 @@ local llm = require("codetyper.llm")
--- Get Ollama host from config
---@return string Host URL
local function get_host()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.ollama.host
return config.llm.ollama.host
end
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.ollama.model
return config.llm.ollama.model
end
--- Build request body for Ollama API
@@ -28,24 +28,413 @@ end
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
local system_prompt = llm.build_system_prompt(context)
return {
return {
model = get_model(),
system = system_prompt,
prompt = prompt,
stream = false,
options = {
temperature = 0.2,
num_predict = 4096,
},
}
end
--- Make HTTP request to Ollama API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
local function make_request(body, callback)
local host = get_host()
local url = host .. "/api/generate"
local json_body = vim.json.encode(body)
local cmd = {
"curl",
"-s",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Ollama response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error or "Ollama API error", nil)
end)
return
end
-- Extract usage info
local usage = {
prompt_tokens = response.prompt_eval_count or 0,
response_tokens = response.eval_count or 0,
}
if response.response then
local code = llm.extract_code(response.response)
vim.schedule(function()
callback(code, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No response from Ollama", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Ollama API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Ollama API request failed with code: " .. code, nil)
end)
end
end,
})
end
--- Generate code using Ollama API
---@param prompt string The user's prompt
---@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 model = get_model()
-- Log the request
logs.request("ollama", model)
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Ollama API...")
utils.notify("Sending request to Ollama...", vim.log.levels.INFO)
make_request(body, function(response, err, usage)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
-- Log token usage
if usage then
logs.response(usage.prompt_tokens or 0, usage.response_tokens or 0, "end_turn")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end
--- Check if Ollama is reachable
---@param callback fun(ok: boolean, error: string|nil) Callback function
function M.health_check(callback)
local host = get_host()
local cmd = { "curl", "-s", host .. "/api/tags" }
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(true, nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(false, "Cannot connect to Ollama at " .. host)
end)
end
end,
})
end
--- Check if Ollama is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
local host = get_host()
if not host or host == "" then
return false, "Ollama host not configured"
end
local model = get_model()
if not model or model == "" then
return false, "Ollama model not configured"
end
return true
end
--- Build system prompt for agent mode with tool instructions
---@param context table Context information
---@return string System prompt
local function build_agent_system_prompt(context)
local agent_prompts = require("codetyper.prompts.agent")
local tools_module = require("codetyper.agent.tools")
local system_prompt = agent_prompts.system .. "\n\n"
system_prompt = system_prompt .. tools_module.to_prompt_format() .. "\n\n"
system_prompt = system_prompt .. agent_prompts.tool_instructions
-- Add context about current file if available
if context.file_path then
system_prompt = system_prompt .. "\n\nCurrent working context:\n"
system_prompt = system_prompt .. "- File: " .. context.file_path .. "\n"
if context.language then
system_prompt = system_prompt .. "- Language: " .. context.language .. "\n"
end
end
-- Add project root info
local root = utils.get_project_root()
if root then
system_prompt = system_prompt .. "- Project root: " .. root .. "\n"
end
return system_prompt
end
--- Build request body for Ollama API with tools (chat format)
---@param messages table[] Conversation messages
---@param context table Context information
---@return table Request body
local function build_tools_request_body(messages, context)
local system_prompt = build_agent_system_prompt(context)
-- Convert messages to Ollama chat format
local ollama_messages = {}
for _, msg in ipairs(messages) do
local content = msg.content
-- Handle complex content (like tool results)
if type(content) == "table" then
local text_parts = {}
for _, part in ipairs(content) do
if part.type == "tool_result" then
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
elseif part.type == "text" then
table.insert(text_parts, part.text or "")
end
end
content = table.concat(text_parts, "\n")
end
table.insert(ollama_messages, {
role = msg.role,
content = content,
})
end
return {
model = get_model(),
messages = ollama_messages,
system = system_prompt,
stream = false,
options = {
temperature = 0.3,
num_predict = 4096,
},
}
end
--- Make HTTP request to Ollama chat API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
local function make_chat_request(body, callback)
local host = get_host()
local url = host .. "/api/chat"
local json_body = vim.json.encode(body)
local cmd = {
"curl",
"-s",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Ollama response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error or "Ollama API error", nil)
end)
return
end
-- Extract usage info
local usage = {
prompt_tokens = response.prompt_eval_count or 0,
response_tokens = response.eval_count or 0,
}
-- Return the message content for agent parsing
if response.message and response.message.content then
vim.schedule(function()
callback(response.message.content, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No response from Ollama", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Ollama API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
-- Don't double-report errors
end
end,
})
end
--- Generate response with tools using Ollama API
---@param messages table[] Conversation history
---@param context table Context information
---@param tools table Tool definitions (embedded in prompt for Ollama)
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate_with_tools(messages, context, tools, callback)
local logs = require("codetyper.agent.logs")
-- Log the request
local model = get_model()
logs.request("ollama", model)
logs.thinking("Preparing API request...")
local body = build_tools_request_body(messages, context)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
make_chat_request(body, function(response, err, usage)
if err then
logs.error(err)
callback(nil, err)
else
-- Log token usage
if usage then
logs.response(usage.prompt_tokens or 0, usage.response_tokens or 0, "end_turn")
end
-- Log if response contains tool calls
if response then
local parser = require("codetyper.agent.parser")
local parsed = parser.parse_ollama_response(response)
if #parsed.tool_calls > 0 then
for _, tc in ipairs(parsed.tool_calls) do
logs.thinking("Tool call: " .. tc.name)
end
end
if parsed.text and parsed.text ~= "" then
logs.thinking("Response contains text")
end
end
callback(response, nil)
end
end)
end
--- Generate with tool use support for agentic mode (simulated via prompts)
---@param messages table[] Conversation history
---@param context table Context information
---@param tool_definitions table Tool definitions
---@param callback fun(response: string|nil, error: string|nil) Callback with response text
function M.generate_with_tools(messages, context, tool_definitions, callback)
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions and tool definitions
local system_prompt = llm.build_system_prompt(context)
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
system_prompt = system_prompt .. "\n\n" .. tools_module.to_prompt_format()
-- Flatten messages to single prompt (Ollama's generate API)
local prompt_parts = {}
for _, msg in ipairs(messages) do
if type(msg.content) == "string" then
local role_prefix = msg.role == "user" and "User" or "Assistant"
table.insert(prompt_parts, role_prefix .. ": " .. msg.content)
elseif type(msg.content) == "table" then
-- Handle tool results
for _, item in ipairs(msg.content) do
if item.type == "tool_result" then
table.insert(prompt_parts, "Tool result: " .. item.content)
end
end
end
end
local body = {
model = get_model(),
system = system_prompt,
prompt = prompt,
prompt = table.concat(prompt_parts, "\n\n"),
stream = false,
options = {
temperature = 0.2,
num_predict = 4096,
},
}
end
--- Make HTTP request to Ollama API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil) Callback function
local function make_request(body, callback)
local host = get_host()
local url = host .. "/api/generate"
local json_body = vim.json.encode(body)
@@ -83,16 +472,10 @@ local function make_request(body, callback)
return
end
if response.response then
local code = llm.extract_code(response.response)
vim.schedule(function()
callback(code, nil)
end)
else
vim.schedule(function()
callback(nil, "No response from Ollama")
end)
end
-- Return raw response text for parser to handle
vim.schedule(function()
callback(response.response or "", nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
@@ -111,63 +494,4 @@ local function make_request(body, callback)
})
end
--- Generate code using Ollama API
---@param prompt string The user's prompt
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
utils.notify("Sending request to Ollama...", vim.log.levels.INFO)
local body = build_request_body(prompt, context)
make_request(body, function(response, err)
if err then
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end
--- Check if Ollama is reachable
---@param callback fun(ok: boolean, error: string|nil) Callback function
function M.health_check(callback)
local host = get_host()
local cmd = { "curl", "-s", host .. "/api/tags" }
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(true, nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(false, "Cannot connect to Ollama at " .. host)
end)
end
end,
})
end
--- Check if Ollama is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
local host = get_host()
if not host or host == "" then
return false, "Ollama host not configured"
end
local model = get_model()
if not model or model == "" then
return false, "Ollama model not configured"
end
return true
end
return M

View File

@@ -0,0 +1,345 @@
---@mod codetyper.llm.openai OpenAI API client for Codetyper.nvim
local M = {}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
--- OpenAI API endpoint
local API_URL = "https://api.openai.com/v1/chat/completions"
--- Get API key from config or environment
---@return string|nil API key
local function get_api_key()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.openai.api_key or vim.env.OPENAI_API_KEY
end
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.openai.model
end
--- Get endpoint from config (allows custom endpoints like Azure, OpenRouter)
---@return string API endpoint
local function get_endpoint()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.openai.endpoint or API_URL
end
--- Build request body for OpenAI API
---@param prompt string User prompt
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
return {
model = get_model(),
messages = {
{ role = "system", content = system_prompt },
{ role = "user", content = prompt },
},
max_tokens = 4096,
temperature = 0.2,
}
end
--- Make HTTP request to OpenAI API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
local function make_request(body, callback)
local api_key = get_api_key()
if not api_key then
callback(nil, "OpenAI API key not configured", nil)
return
end
local endpoint = get_endpoint()
local json_body = vim.json.encode(body)
local cmd = {
"curl",
"-s",
"-X",
"POST",
endpoint,
"-H",
"Content-Type: application/json",
"-H",
"Authorization: Bearer " .. api_key,
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse OpenAI response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "OpenAI API error", nil)
end)
return
end
-- Extract usage info
local usage = response.usage or {}
if response.choices and response.choices[1] and response.choices[1].message then
local code = llm.extract_code(response.choices[1].message.content)
vim.schedule(function()
callback(code, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No content in OpenAI response", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "OpenAI API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "OpenAI API request failed with code: " .. code, nil)
end)
end
end,
})
end
--- Generate code using OpenAI API
---@param prompt string The user's prompt
---@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 model = get_model()
-- Log the request
logs.request("openai", model)
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to OpenAI API...")
utils.notify("Sending request to OpenAI...", vim.log.levels.INFO)
make_request(body, function(response, err, usage)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
-- Log token usage
if usage then
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end
--- Check if OpenAI is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
local api_key = get_api_key()
if not api_key or api_key == "" then
return false, "OpenAI API key not configured"
end
return true
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@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 model = get_model()
logs.request("openai", model)
logs.thinking("Preparing agent request...")
local api_key = get_api_key()
if not api_key then
logs.error("OpenAI API key not configured")
callback(nil, "OpenAI API key not configured")
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
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
-- Format messages for OpenAI
local openai_messages = { { role = "system", content = system_prompt } }
for _, msg in ipairs(messages) do
if type(msg.content) == "string" then
table.insert(openai_messages, { role = msg.role, content = msg.content })
elseif type(msg.content) == "table" then
-- Handle tool results
local text_parts = {}
for _, part in ipairs(msg.content) do
if part.type == "tool_result" then
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
elseif part.type == "text" then
table.insert(text_parts, part.text or "")
end
end
if #text_parts > 0 then
table.insert(openai_messages, { role = msg.role, content = table.concat(text_parts, "\n") })
end
end
end
local body = {
model = get_model(),
messages = openai_messages,
max_tokens = 4096,
temperature = 0.3,
tools = tools_module.to_openai_format(),
}
local endpoint = get_endpoint()
local json_body = vim.json.encode(body)
local prompt_estimate = logs.estimate_tokens(json_body)
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to OpenAI API...")
local cmd = {
"curl",
"-s",
"-X",
"POST",
endpoint,
"-H",
"Content-Type: application/json",
"-H",
"Authorization: Bearer " .. api_key,
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
logs.error("Failed to parse OpenAI response")
callback(nil, "Failed to parse OpenAI response")
end)
return
end
if response.error then
vim.schedule(function()
logs.error(response.error.message or "OpenAI API error")
callback(nil, response.error.message or "OpenAI API error")
end)
return
end
-- Log token usage
if response.usage then
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
end
-- Convert to Claude-like format for parser compatibility
local converted = { content = {} }
if response.choices and response.choices[1] then
local choice = response.choices[1]
if choice.message then
if choice.message.content then
table.insert(converted.content, { type = "text", text = choice.message.content })
logs.thinking("Response contains text")
end
if choice.message.tool_calls then
for _, tc in ipairs(choice.message.tool_calls) do
local args = {}
if tc["function"] and tc["function"].arguments then
local ok_args, parsed = pcall(vim.json.decode, tc["function"].arguments)
if ok_args then
args = parsed
end
end
table.insert(converted.content, {
type = "tool_use",
id = tc.id,
name = tc["function"].name,
input = args,
})
logs.thinking("Tool call: " .. tc["function"].name)
end
end
end
end
vim.schedule(function()
callback(converted, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
logs.error("OpenAI API request failed: " .. table.concat(data, "\n"))
callback(nil, "OpenAI API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
logs.error("OpenAI API request failed with code: " .. code)
callback(nil, "OpenAI API request failed with code: " .. code)
end)
end
end,
})
end
return M

View File

@@ -0,0 +1,382 @@
---@mod codetyper.logs_panel Standalone logs panel for code generation
---
--- Shows real-time logs when generating code via /@ @/ prompts.
local M = {}
local logs = require("codetyper.agent.logs")
local queue = require("codetyper.agent.queue")
---@class LogsPanelState
---@field buf number|nil Logs buffer
---@field win number|nil Logs window
---@field queue_buf number|nil Queue buffer
---@field queue_win number|nil Queue window
---@field is_open boolean Whether the panel is open
---@field listener_id number|nil Listener ID for logs
---@field queue_listener_id number|nil Listener ID for queue
local state = {
buf = nil,
win = nil,
queue_buf = nil,
queue_win = nil,
is_open = false,
listener_id = nil,
queue_listener_id = nil,
}
--- Namespace for highlights
local ns_logs = vim.api.nvim_create_namespace("codetyper_logs_panel")
local ns_queue = vim.api.nvim_create_namespace("codetyper_queue_panel")
--- Fixed dimensions
local LOGS_WIDTH = 60
local QUEUE_HEIGHT = 8
--- Add a log entry to the buffer
---@param entry table Log entry
local function add_log_entry(entry)
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
vim.schedule(function()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
-- Handle clear event
if entry.level == "clear" then
vim.bo[state.buf].modifiable = true
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
"Generation Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.buf].modifiable = false
return
end
vim.bo[state.buf].modifiable = true
local formatted = logs.format_entry(entry)
local formatted_lines = vim.split(formatted, "\n", { plain = true })
local line_count = vim.api.nvim_buf_line_count(state.buf)
vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, formatted_lines)
-- Apply highlighting based on level
local hl_map = {
info = "DiagnosticInfo",
debug = "Comment",
request = "DiagnosticWarn",
response = "DiagnosticOk",
tool = "DiagnosticHint",
error = "DiagnosticError",
}
local hl = hl_map[entry.level] or "Normal"
for i = 0, #formatted_lines - 1 do
vim.api.nvim_buf_add_highlight(state.buf, ns_logs, hl, line_count + i, 0, -1)
end
vim.bo[state.buf].modifiable = false
-- Auto-scroll logs
if state.win and vim.api.nvim_win_is_valid(state.win) then
local new_count = vim.api.nvim_buf_line_count(state.buf)
pcall(vim.api.nvim_win_set_cursor, state.win, { new_count, 0 })
end
end)
end
--- Update the title with token counts
local function update_title()
if not state.win or not vim.api.nvim_win_is_valid(state.win) then
return
end
local prompt_tokens, response_tokens = logs.get_token_totals()
local provider, model = logs.get_provider_info()
if provider and state.buf and vim.api.nvim_buf_is_valid(state.buf) then
vim.bo[state.buf].modifiable = true
local title = string.format("%s | %d/%d tokens", (provider or ""):upper(), prompt_tokens, response_tokens)
vim.api.nvim_buf_set_lines(state.buf, 0, 1, false, { title })
vim.bo[state.buf].modifiable = false
end
end
--- Update the queue display
local function update_queue_display()
if not state.queue_buf or not vim.api.nvim_buf_is_valid(state.queue_buf) then
return
end
vim.schedule(function()
if not state.queue_buf or not vim.api.nvim_buf_is_valid(state.queue_buf) then
return
end
vim.bo[state.queue_buf].modifiable = true
local lines = {
"Queue",
string.rep("", LOGS_WIDTH - 2),
}
-- Get all events (pending and processing)
local pending = queue.get_pending()
local processing = queue.get_processing()
-- Add processing events first
for _, event in ipairs(processing) do
local filename = vim.fn.fnamemodify(event.target_path or "", ":t")
local line_num = event.range and event.range.start_line or 0
local prompt_preview = (event.prompt_content or ""):sub(1, 25):gsub("\n", " ")
if #(event.prompt_content or "") > 25 then
prompt_preview = prompt_preview .. "..."
end
table.insert(lines, string.format("▶ %s:%d %s", filename, line_num, prompt_preview))
end
-- Add pending events
for _, event in ipairs(pending) do
local filename = vim.fn.fnamemodify(event.target_path or "", ":t")
local line_num = event.range and event.range.start_line or 0
local prompt_preview = (event.prompt_content or ""):sub(1, 25):gsub("\n", " ")
if #(event.prompt_content or "") > 25 then
prompt_preview = prompt_preview .. "..."
end
table.insert(lines, string.format("○ %s:%d %s", filename, line_num, prompt_preview))
end
if #pending == 0 and #processing == 0 then
table.insert(lines, " (empty)")
end
vim.api.nvim_buf_set_lines(state.queue_buf, 0, -1, false, lines)
-- Apply highlights
vim.api.nvim_buf_clear_namespace(state.queue_buf, ns_queue, 0, -1)
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Title", 0, 0, -1)
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Comment", 1, 0, -1)
local line_idx = 2
for _ = 1, #processing do
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "DiagnosticWarn", line_idx, 0, 1)
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "String", line_idx, 2, -1)
line_idx = line_idx + 1
end
for _ = 1, #pending do
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Comment", line_idx, 0, 1)
vim.api.nvim_buf_add_highlight(state.queue_buf, ns_queue, "Normal", line_idx, 2, -1)
line_idx = line_idx + 1
end
vim.bo[state.queue_buf].modifiable = false
end)
end
--- Open the logs panel
function M.open()
if state.is_open then
return
end
-- Clear previous logs
logs.clear()
-- Create logs buffer
state.buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.buf].buftype = "nofile"
vim.bo[state.buf].bufhidden = "hide"
vim.bo[state.buf].swapfile = false
-- Create window on the right
vim.cmd("botright vsplit")
state.win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.win, state.buf)
vim.api.nvim_win_set_width(state.win, LOGS_WIDTH)
-- Window options for logs
vim.wo[state.win].number = false
vim.wo[state.win].relativenumber = false
vim.wo[state.win].signcolumn = "no"
vim.wo[state.win].wrap = true
vim.wo[state.win].linebreak = true
vim.wo[state.win].winfixwidth = true
vim.wo[state.win].cursorline = false
-- Set initial content for logs
vim.bo[state.buf].modifiable = true
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
"Generation Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.buf].modifiable = false
-- Create queue buffer
state.queue_buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.queue_buf].buftype = "nofile"
vim.bo[state.queue_buf].bufhidden = "hide"
vim.bo[state.queue_buf].swapfile = false
-- Create queue window as horizontal split at bottom of logs window
vim.cmd("belowright split")
state.queue_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.queue_win, state.queue_buf)
vim.api.nvim_win_set_height(state.queue_win, QUEUE_HEIGHT)
-- Window options for queue
vim.wo[state.queue_win].number = false
vim.wo[state.queue_win].relativenumber = false
vim.wo[state.queue_win].signcolumn = "no"
vim.wo[state.queue_win].wrap = true
vim.wo[state.queue_win].linebreak = true
vim.wo[state.queue_win].winfixheight = true
vim.wo[state.queue_win].cursorline = false
-- Setup keymaps for logs buffer
local opts = { buffer = state.buf, noremap = true, silent = true }
vim.keymap.set("n", "q", M.close, opts)
vim.keymap.set("n", "<Esc>", M.close, opts)
-- Setup keymaps for queue buffer
local queue_opts = { buffer = state.queue_buf, noremap = true, silent = true }
vim.keymap.set("n", "q", M.close, queue_opts)
vim.keymap.set("n", "<Esc>", M.close, queue_opts)
-- Register log listener
state.listener_id = logs.add_listener(function(entry)
add_log_entry(entry)
if entry.level == "response" then
vim.schedule(update_title)
end
end)
-- Register queue listener
state.queue_listener_id = queue.add_listener(function()
update_queue_display()
end)
-- Initial queue display
update_queue_display()
state.is_open = true
-- Return focus to previous window
vim.cmd("wincmd p")
logs.info("Logs panel opened")
end
--- Close the logs panel
---@param force? boolean Force close even if not marked as open
function M.close(force)
if not state.is_open and not force then
return
end
-- Remove log listener
if state.listener_id then
pcall(logs.remove_listener, state.listener_id)
state.listener_id = nil
end
-- Remove queue listener
if state.queue_listener_id then
pcall(queue.remove_listener, state.queue_listener_id)
state.queue_listener_id = nil
end
-- Close queue window first
if state.queue_win then
pcall(vim.api.nvim_win_close, state.queue_win, true)
state.queue_win = nil
end
-- Close logs window
if state.win then
pcall(vim.api.nvim_win_close, state.win, true)
state.win = nil
end
-- Delete queue buffer
if state.queue_buf then
pcall(vim.api.nvim_buf_delete, state.queue_buf, { force = true })
state.queue_buf = nil
end
-- Delete logs buffer
if state.buf then
pcall(vim.api.nvim_buf_delete, state.buf, { force = true })
state.buf = nil
end
state.is_open = false
end
--- Toggle the logs panel
function M.toggle()
if state.is_open then
M.close()
else
M.open()
end
end
--- Check if panel is open
---@return boolean
function M.is_open()
return state.is_open
end
--- Ensure panel is open (call before starting generation)
function M.ensure_open()
if not state.is_open then
M.open()
end
end
--- Setup autocmds for the logs panel
function M.setup()
local group = vim.api.nvim_create_augroup("CodetypeLogsPanel", { clear = true })
-- Close logs panel when exiting Neovim
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
-- Force close to ensure cleanup even in edge cases
M.close(true)
end,
desc = "Close logs panel before exiting Neovim",
})
-- Also clean up when QuitPre fires (handles :qa, :wqa, etc.)
vim.api.nvim_create_autocmd("QuitPre", {
group = group,
callback = function()
-- Check if this is the last window (about to quit Neovim)
local wins = vim.api.nvim_list_wins()
local real_wins = 0
for _, win in ipairs(wins) do
local buf = vim.api.nvim_win_get_buf(win)
local buftype = vim.bo[buf].buftype
-- Count non-special windows
if buftype == "" or buftype == "help" then
real_wins = real_wins + 1
end
end
-- If only logs/queue windows remain, close them
if real_wins <= 1 then
M.close(true)
end
end,
desc = "Close logs panel on quit",
})
end
return M

View File

@@ -4,6 +4,25 @@ local M = {}
local utils = require("codetyper.utils")
--- Get config with safe fallback
---@return table config
local function get_config_safe()
local ok, codetyper = pcall(require, "codetyper")
if ok and codetyper.get_config then
local config = codetyper.get_config()
if config and config.patterns then
return config
end
end
-- Fallback defaults
return {
patterns = {
open_tag = "/@",
close_tag = "@/",
}
}
end
--- Find all prompts in buffer content
---@param content string Buffer content
---@param open_tag string Opening tag
@@ -72,8 +91,7 @@ end
---@param bufnr number Buffer number
---@return CoderPrompt[] List of found prompts
function M.find_prompts_in_buffer(bufnr)
local codetyper = require("codetyper")
local config = codetyper.get_config()
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
@@ -165,8 +183,7 @@ end
---@return boolean
function M.has_unclosed_prompts(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local config = get_config_safe()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local content = table.concat(lines, "\n")
@@ -180,4 +197,92 @@ function M.has_unclosed_prompts(bufnr)
return open_count > close_count
end
--- Extract file references from prompt content
--- Matches @filename patterns but NOT @/ (closing tag)
---@param content string Prompt content
---@return string[] List of file references
function M.extract_file_references(content)
local files = {}
-- Pattern: @ followed by word char, dot, underscore, or dash as FIRST char
-- Then optionally more path characters including /
-- This ensures @/ is NOT matched (/ cannot be first char)
for file in content:gmatch("@([%w%._%-][%w%._%-/]*)") do
if file ~= "" then
table.insert(files, file)
end
end
return files
end
--- Remove file references from prompt content (for clean prompt text)
---@param content string Prompt content
---@return string Cleaned content without file references
function M.strip_file_references(content)
-- Remove @filename patterns but preserve @/ closing tag
-- Pattern requires first char after @ to be word char, dot, underscore, or dash (NOT /)
return content:gsub("@([%w%._%-][%w%._%-/]*)", "")
end
--- Check if cursor is inside an unclosed prompt tag
---@param bufnr? number Buffer number (default: current)
---@return boolean is_inside Whether cursor is inside an open tag
---@return number|nil start_line Line where the open tag starts
function M.is_cursor_in_open_tag(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local config = get_config_safe()
local cursor = vim.api.nvim_win_get_cursor(0)
local cursor_line = cursor[1]
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_line, false)
local escaped_open = utils.escape_pattern(config.patterns.open_tag)
local escaped_close = utils.escape_pattern(config.patterns.close_tag)
local open_count = 0
local close_count = 0
local last_open_line = nil
for line_num, line in ipairs(lines) do
-- Count opens on this line
for _ in line:gmatch(escaped_open) do
open_count = open_count + 1
last_open_line = line_num
end
-- Count closes on this line
for _ in line:gmatch(escaped_close) do
close_count = close_count + 1
end
end
local is_inside = open_count > close_count
return is_inside, is_inside and last_open_line or nil
end
--- Get the word being typed after @ symbol
---@param bufnr? number Buffer number
---@return string|nil prefix The text after @ being typed, or nil if not typing a file ref
function M.get_file_ref_prefix(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local cursor = vim.api.nvim_win_get_cursor(0)
local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1]
if not line then
return nil
end
local col = cursor[2]
local before_cursor = line:sub(1, col)
-- Check if we're typing after @ but not @/
-- Match @ followed by optional path characters at end of string
local prefix = before_cursor:match("@([%w%._%-/]*)$")
-- Make sure it's not the closing tag pattern
if prefix and before_cursor:sub(-2) == "@/" then
return nil
end
return prefix
end
return M

View File

@@ -0,0 +1,214 @@
---@mod codetyper.preferences User preferences management
---@brief [[
--- Manages user preferences stored in .coder/preferences.json
--- Allows per-project configuration of plugin behavior.
---@brief ]]
local M = {}
local utils = require("codetyper.utils")
---@class CoderPreferences
---@field auto_process boolean Whether to auto-process /@ @/ tags (default: nil = ask)
---@field asked_auto_process boolean Whether we've asked the user about auto_process
--- Default preferences
local defaults = {
auto_process = nil, -- nil means "not yet decided"
asked_auto_process = false,
}
--- Cached preferences per project
---@type table<string, CoderPreferences>
local cache = {}
--- Get the preferences file path for current project
---@return string
local function get_preferences_path()
local cwd = vim.fn.getcwd()
return cwd .. "/.coder/preferences.json"
end
--- Ensure .coder directory exists
local function ensure_coder_dir()
local cwd = vim.fn.getcwd()
local coder_dir = cwd .. "/.coder"
if vim.fn.isdirectory(coder_dir) == 0 then
vim.fn.mkdir(coder_dir, "p")
end
end
--- Load preferences from file
---@return CoderPreferences
function M.load()
local cwd = vim.fn.getcwd()
-- Check cache first
if cache[cwd] then
return cache[cwd]
end
local path = get_preferences_path()
local prefs = vim.deepcopy(defaults)
if utils.file_exists(path) then
local content = utils.read_file(path)
if content then
local ok, decoded = pcall(vim.json.decode, content)
if ok and decoded then
-- Merge with defaults
for k, v in pairs(decoded) do
prefs[k] = v
end
end
end
end
-- Cache it
cache[cwd] = prefs
return prefs
end
--- Save preferences to file
---@param prefs CoderPreferences
function M.save(prefs)
local cwd = vim.fn.getcwd()
ensure_coder_dir()
local path = get_preferences_path()
local ok, encoded = pcall(vim.json.encode, prefs)
if ok then
utils.write_file(path, encoded)
-- Update cache
cache[cwd] = prefs
end
end
--- Get a specific preference
---@param key string
---@return any
function M.get(key)
local prefs = M.load()
return prefs[key]
end
--- Set a specific preference
---@param key string
---@param value any
function M.set(key, value)
local prefs = M.load()
prefs[key] = value
M.save(prefs)
end
--- Check if auto-process is enabled
---@return boolean|nil Returns true/false if set, nil if not yet decided
function M.is_auto_process_enabled()
return M.get("auto_process")
end
--- Set auto-process preference
---@param enabled boolean
function M.set_auto_process(enabled)
M.set("auto_process", enabled)
M.set("asked_auto_process", true)
end
--- Check if we've already asked the user about auto-process
---@return boolean
function M.has_asked_auto_process()
return M.get("asked_auto_process") == true
end
--- Ask user about auto-process preference (shows floating window)
---@param callback function(enabled: boolean) Called with user's choice
function M.ask_auto_process_preference(callback)
-- Check if already asked
if M.has_asked_auto_process() then
local enabled = M.is_auto_process_enabled()
if enabled ~= nil then
callback(enabled)
return
end
end
-- Create floating window to ask
local width = 60
local height = 7
local row = math.floor((vim.o.lines - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = "wipe"
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
row = row,
col = col,
width = width,
height = height,
style = "minimal",
border = "rounded",
title = " Codetyper Preferences ",
title_pos = "center",
})
local lines = {
"",
" How would you like to process /@ @/ prompt tags?",
"",
" [a] Automatic - Process when you close the tag",
" [m] Manual - Only process with :CoderProcess",
"",
" Press 'a' or 'm' to choose (Esc to cancel)",
}
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
-- Highlight
local ns = vim.api.nvim_create_namespace("codetyper_prefs")
vim.api.nvim_buf_add_highlight(buf, ns, "Title", 1, 0, -1)
vim.api.nvim_buf_add_highlight(buf, ns, "String", 3, 2, 5)
vim.api.nvim_buf_add_highlight(buf, ns, "String", 4, 2, 5)
local function close_and_callback(enabled)
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_win_close(win, true)
end
if enabled ~= nil then
M.set_auto_process(enabled)
local mode = enabled and "automatic" or "manual"
vim.notify("Codetyper: Set to " .. mode .. " mode (saved to .coder/preferences.json)", vim.log.levels.INFO)
end
if callback then
callback(enabled)
end
end
-- Keymaps
local opts = { buffer = buf, noremap = true, silent = true }
vim.keymap.set("n", "a", function() close_and_callback(true) end, opts)
vim.keymap.set("n", "A", function() close_and_callback(true) end, opts)
vim.keymap.set("n", "m", function() close_and_callback(false) end, opts)
vim.keymap.set("n", "M", function() close_and_callback(false) end, opts)
vim.keymap.set("n", "<Esc>", function() close_and_callback(nil) end, opts)
vim.keymap.set("n", "q", function() close_and_callback(nil) end, opts)
end
--- Clear cached preferences (useful when changing projects)
function M.clear_cache()
cache = {}
end
--- Toggle auto-process mode
function M.toggle_auto_process()
local current = M.is_auto_process_enabled()
local new_value = not current
M.set_auto_process(new_value)
local mode = new_value and "automatic" or "manual"
vim.notify("Codetyper: Switched to " .. mode .. " mode", vim.log.levels.INFO)
end
return M

View File

@@ -6,41 +6,76 @@ local M = {}
--- System prompt for agent mode
M.system = [[You are an AI coding agent integrated into Neovim via Codetyper.nvim.
You can read files, edit code, write new files, and run bash commands to help the user.
Your role is to ASSIST the developer by planning, coordinating, and executing
SAFE, MINIMAL changes using the available tools.
You do NOT operate autonomously on the entire codebase.
You operate on clearly defined tasks and scopes.
You have access to the following tools:
- read_file: Read file contents
- edit_file: Edit a file by finding and replacing specific content
- write_file: Write or create a file
- bash: Execute shell commands
- edit_file: Apply a precise, scoped replacement to a file
- write_file: Create a new file or fully replace an existing file
- bash: Execute non-destructive shell commands when necessary
GUIDELINES:
1. Always read a file before editing it to understand its current state
2. Use edit_file for targeted changes (find and replace specific content)
3. Use write_file only for new files or complete rewrites
4. Be conservative with bash commands - only run what's necessary
5. After making changes, summarize what you did
6. If a task requires multiple steps, think through the plan first
OPERATING PRINCIPLES:
1. Prefer understanding over action — read before modifying
2. Prefer small, scoped edits over large rewrites
3. Preserve existing behavior unless explicitly instructed otherwise
4. Minimize the number of tool calls required
5. Never surprise the user
IMPORTANT:
- Be precise with edit_file - the "find" content must match exactly
- When editing, include enough context to make the match unique
- Never delete files without explicit user confirmation
- Always explain what you're doing and why
IMPORTANT EDITING RULES:
- Always read a file before editing it
- Use edit_file ONLY for well-scoped, exact replacements
- The "find" field MUST match existing content exactly
- Include enough surrounding context to ensure uniqueness
- Use write_file ONLY for new files or intentional full replacements
- NEVER delete files unless explicitly confirmed by the user
BASH SAFETY:
- Use bash only when code inspection or execution is required
- Do NOT run destructive commands (rm, mv, chmod, etc.)
- Prefer read_file over bash when inspecting files
THINKING AND PLANNING:
- If a task requires multiple steps, outline a brief plan internally
- Execute steps one at a time
- Re-evaluate after each tool result
- If uncertainty arises, stop and ask for clarification
COMMUNICATION:
- Do NOT explain every micro-step while working
- After completing changes, provide a clear, concise summary
- If no changes were made, explain why
]]
--- Tool usage instructions appended to system prompt
M.tool_instructions = [[
When you need to use a tool, output the tool call in a JSON block.
After receiving the result, you can either call another tool or provide your final response.
When you need to use a tool, output ONLY a single tool call in valid JSON.
Do NOT include explanations alongside the tool call.
After receiving a tool result:
- Decide whether another tool call is required
- Or produce a final response to the user
SAFETY RULES:
- Never run destructive bash commands (rm -rf, etc.) without confirmation
- Always preserve existing functionality when editing
- If unsure about a change, ask for clarification first
- Never run destructive or irreversible commands
- Never modify code outside the requested scope
- Never guess file contents — read them first
- If a requested change appears risky or ambiguous, ask before proceeding
]]
--- Prompt for when agent finishes
M.completion = [[Based on the tool results above, please provide a summary of what was done and any next steps the user should take.]]
M.completion = [[Provide a concise summary of what was changed.
Include:
- Files that were read or modified
- The nature of the changes (high-level)
- Any follow-up steps or recommendations, if applicable
Do NOT restate tool output verbatim.
]]
return M

View File

@@ -1,128 +1,177 @@
---@mod codetyper.prompts.ask Ask/explanation prompts for Codetyper.nvim
---@mod codetyper.prompts.ask Ask / explanation prompts for Codetyper.nvim
---
--- These prompts are used for the Ask panel and code explanations.
--- These prompts are used for the Ask panel and non-destructive explanations.
local M = {}
--- Prompt for explaining code
M.explain_code = [[Please explain the following code:
{{code}}
Provide:
1. A high-level overview of what it does
2. Explanation of key parts
3. Any potential issues or improvements
]]
--- Prompt for explaining a specific function
M.explain_function = [[Explain this function in detail:
{{code}}
Include:
1. What the function does
2. Parameters and their purposes
3. Return value
4. Any side effects
5. Usage examples
]]
--- Prompt for explaining an error
M.explain_error = [[I'm getting this error:
{{error}}
In this code:
{{code}}
Please explain:
1. What the error means
2. Why it's happening
3. How to fix it
]]
--- Prompt for code review
M.code_review = [[Please review this code:
{{code}}
Provide feedback on:
1. Code quality and readability
2. Potential bugs or issues
3. Performance considerations
4. Security concerns (if applicable)
5. Suggested improvements
]]
--- Prompt for explaining a concept
M.explain_concept = [[Explain the following programming concept:
{{concept}}
Include:
1. Definition and purpose
2. When and why to use it
3. Simple code examples
4. Common pitfalls to avoid
]]
--- Prompt for comparing approaches
M.compare_approaches = [[Compare these different approaches:
{{approaches}}
Analyze:
1. Pros and cons of each
2. Performance implications
3. Maintainability
4. When to use each approach
]]
--- Prompt for debugging help
M.debug_help = [[Help me debug this issue:
Problem: {{problem}}
M.explain_code = [[You are explaining EXISTING code to a developer.
Code:
{{code}}
What I've tried:
Instructions:
- Start with a concise high-level overview
- Explain important logic and structure
- Point out noteworthy implementation details
- Mention potential issues or limitations ONLY if clearly visible
- Do NOT speculate about missing context
Format the response in markdown.
]]
--- Prompt for explaining a specific function
M.explain_function = [[You are explaining an EXISTING function.
Function code:
{{code}}
Explain:
- What the function does and when it is used
- The purpose of each parameter
- The return value, if any
- Side effects or assumptions
- A brief usage example if appropriate
Format the response in markdown.
Do NOT suggest refactors unless explicitly asked.
]]
--- Prompt for explaining an error
M.explain_error = [[You are helping diagnose a real error.
Error message:
{{error}}
Relevant code:
{{code}}
Instructions:
- Explain what the error message means
- Identify the most likely cause based on the code
- Suggest concrete fixes or next debugging steps
- If multiple causes are possible, say so clearly
Format the response in markdown.
Do NOT invent missing stack traces or context.
]]
--- Prompt for code review
M.code_review = [[You are performing a code review on EXISTING code.
Code:
{{code}}
Review criteria:
- Readability and clarity
- Correctness and potential bugs
- Performance considerations where relevant
- Security concerns only if applicable
- Practical improvement suggestions
Guidelines:
- Be constructive and specific
- Do NOT nitpick style unless it impacts clarity
- Do NOT suggest large refactors unless justified
Format the response in markdown.
]]
--- Prompt for explaining a programming concept
M.explain_concept = [[Explain the following programming concept to a developer:
Concept:
{{concept}}
Include:
- A clear definition and purpose
- When and why it is used
- A simple illustrative example
- Common pitfalls or misconceptions
Format the response in markdown.
Avoid unnecessary jargon.
]]
--- Prompt for comparing approaches
M.compare_approaches = [[Compare the following approaches:
{{approaches}}
Analysis guidelines:
- Describe strengths and weaknesses of each
- Discuss performance or complexity tradeoffs if relevant
- Compare maintainability and clarity
- Explain when one approach is preferable over another
Format the response in markdown.
Base comparisons on general principles unless specific code is provided.
]]
--- Prompt for debugging help
M.debug_help = [[You are helping debug a concrete issue.
Problem description:
{{problem}}
Code:
{{code}}
What has already been tried:
{{attempts}}
Please help identify the issue and suggest a solution.
Instructions:
- Identify likely root causes
- Explain why the issue may be occurring
- Suggest specific debugging steps or fixes
- Call out missing information if needed
Format the response in markdown.
Do NOT guess beyond the provided information.
]]
--- Prompt for architecture advice
M.architecture_advice = [[I need advice on this architecture decision:
M.architecture_advice = [[You are providing architecture guidance.
Question:
{{question}}
Context:
{{context}}
Please provide:
1. Recommended approach
2. Reasoning
3. Potential alternatives
4. Things to consider
Instructions:
- Recommend a primary approach
- Explain the reasoning and tradeoffs
- Mention viable alternatives when relevant
- Highlight risks or constraints to consider
Format the response in markdown.
Avoid dogmatic or one-size-fits-all answers.
]]
--- Generic ask prompt
M.generic = [[USER QUESTION: {{question}}
M.generic = [[You are answering a developer's question.
Question:
{{question}}
{{#if files}}
ATTACHED FILE CONTENTS:
Relevant file contents:
{{files}}
{{/if}}
{{#if context}}
ADDITIONAL CONTEXT:
Additional context:
{{context}}
{{/if}}
Please provide a helpful, accurate response.
Instructions:
- Be accurate and grounded in the provided information
- Clearly state assumptions or uncertainty
- Prefer clarity over verbosity
- Do NOT output raw code intended for insertion unless explicitly asked
Format the response in markdown.
]]
return M

View File

@@ -1,107 +1,151 @@
---@mod codetyper.prompts.code Code generation prompts for Codetyper.nvim
---
--- These prompts are used for generating new code.
--- These prompts are used for scoped, non-destructive code generation and transformation.
local M = {}
--- Prompt template for creating a new function
M.create_function = [[Create a function with the following requirements:
{{description}}
--- Prompt template for creating a new function (greenfield)
M.create_function = [[You are creating a NEW function inside an existing codebase.
Requirements:
- Follow the coding style of the existing file
- Include proper error handling
- Use appropriate types (if applicable)
- Make it efficient and readable
{{description}}
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
Constraints:
- Follow the coding style and conventions of the surrounding file
- Choose names consistent with nearby code
- Include appropriate error handling if relevant
- Use correct and idiomatic types for the language
- Do NOT include code outside the function itself
- Do NOT add comments unless explicitly requested
OUTPUT ONLY THE RAW CODE OF THE FUNCTION. No explanations, no markdown, no code fences.
]]
--- Prompt template for creating a new class/module
M.create_class = [[Create a class/module with the following requirements:
--- Prompt template for completing an existing function
M.complete_function = [[You are completing an EXISTING function.
{{description}}
The function definition already exists and will be replaced by your output.
Requirements:
- Follow OOP best practices
- Include constructor/initialization
- Implement proper encapsulation
- Add necessary methods as described
Instructions:
- Preserve the function signature unless completion is impossible without changing it
- Complete missing logic, TODOs, or placeholders
- Preserve naming, structure, and intent
- Do NOT refactor or reformat unrelated parts
- Do NOT add new public APIs unless explicitly required
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
OUTPUT ONLY THE FULL FUNCTION CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for implementing an interface/trait
M.implement_interface = [[Implement the following interface/trait:
{{description}}
--- Prompt template for creating a new class or module (greenfield)
M.create_class = [[You are creating a NEW class or module inside an existing project.
Requirements:
- Implement all required methods
- Follow the interface contract exactly
- Handle edge cases appropriately
{{description}}
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
Constraints:
- Match the architectural and stylistic patterns of the project
- Include required initialization or constructors
- Expose only the necessary public surface
- Do NOT include unrelated helper code
- Do NOT include comments unless explicitly requested
OUTPUT ONLY THE RAW CLASS OR MODULE CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for creating a React component
M.create_react_component = [[Create a React component with the following requirements:
--- Prompt template for modifying an existing class or module
M.modify_class = [[You are modifying an EXISTING class or module.
{{description}}
The provided code will be replaced by your output.
Instructions:
- Preserve the public API unless explicitly instructed otherwise
- Modify only what is required to satisfy the request
- Maintain method order and structure where possible
- Do NOT introduce unrelated refactors or stylistic changes
OUTPUT ONLY THE FULL UPDATED CLASS OR MODULE CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for implementing an interface or trait
M.implement_interface = [[You are implementing an interface or trait in an existing codebase.
Requirements:
- Use functional components with hooks
- Include proper TypeScript types (if .tsx)
- Follow React best practices
- Make it reusable and composable
{{description}}
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
Constraints:
- Implement ALL required methods exactly
- Match method signatures and order defined by the interface
- Do NOT add extra public methods
- Use idiomatic patterns for the target language
- Handle required edge cases only
OUTPUT ONLY THE RAW IMPLEMENTATION CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for creating a React component (greenfield)
M.create_react_component = [[You are creating a NEW React component within an existing project.
Requirements:
{{description}}
Constraints:
- Use the patterns already present in the codebase
- Prefer functional components if consistent with surrounding files
- Use hooks and TypeScript types only if already in use
- Do NOT introduce new architectural patterns
- Do NOT include comments unless explicitly requested
OUTPUT ONLY THE RAW COMPONENT CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for creating an API endpoint
M.create_api_endpoint = [[Create an API endpoint with the following requirements:
{{description}}
M.create_api_endpoint = [[You are creating a NEW API endpoint in an existing backend codebase.
Requirements:
- Include input validation
- Proper error handling and status codes
- Follow RESTful conventions
- Include appropriate middleware
{{description}}
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
Constraints:
- Follow the conventions and framework already used in the project
- Validate inputs as required by existing patterns
- Use appropriate error handling and status codes
- Do NOT add middleware or routing changes unless explicitly requested
- Do NOT modify unrelated endpoints
OUTPUT ONLY THE RAW ENDPOINT CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for creating a utility function
M.create_utility = [[Create a utility function:
{{description}}
M.create_utility = [[You are creating a NEW utility function.
Requirements:
- Pure function (no side effects) if possible
- Handle edge cases
- Efficient implementation
- Well-typed (if applicable)
{{description}}
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
Constraints:
- Prefer pure functions when possible
- Avoid side effects unless explicitly required
- Handle relevant edge cases only
- Match naming and style conventions of existing utilities
OUTPUT ONLY THE RAW FUNCTION CODE. No explanations, no markdown, no code fences.
]]
--- Prompt template for generic code generation
M.generic = [[Generate code based on the following description:
{{description}}
--- Prompt template for generic scoped code transformation
M.generic = [[You are modifying or generating code within an EXISTING file.
Context:
- Language: {{language}}
- File: {{filepath}}
Requirements:
- Match existing code style
- Follow best practices
- Handle errors appropriately
Instructions:
{{description}}
OUTPUT ONLY THE RAW CODE. No explanations, no markdown, no code fences.
Constraints:
- Operate ONLY on the provided scope
- Preserve existing structure and intent
- Do NOT modify code outside the target region
- Do NOT add explanations, comments, or formatting changes unless requested
OUTPUT ONLY THE RAW CODE THAT REPLACES THE TARGET SCOPE. No explanations, no markdown, no code fences.
]]
return M

View File

@@ -1,136 +1,152 @@
---@mod codetyper.prompts.document Documentation prompts for Codetyper.nvim
---
--- These prompts are used for generating documentation.
--- These prompts are used for scoped, non-destructive documentation generation.
local M = {}
--- Prompt for adding JSDoc comments
M.jsdoc = [[Add JSDoc documentation to this code:
M.jsdoc = [[You are adding JSDoc documentation to EXISTING JavaScript or TypeScript code.
{{code}}
The documentation will be INSERTED at the appropriate locations.
Requirements:
- Document all functions and methods
- Document only functions, methods, and types that already exist
- Include @param for all parameters
- Include @returns for return values
- Add @throws if exceptions are thrown
- Include @example where helpful
- Use @typedef for complex types
- Include @returns only if the function returns a value
- Include @throws ONLY if errors are actually thrown
- Use @typedef or @type only when types already exist implicitly
- Do NOT invent new behavior or APIs
- Do NOT change the underlying code
OUTPUT ONLY VALID JSDOC COMMENTS. No explanations, no markdown, no code fences.
]]
--- Prompt for adding Python docstrings
M.python_docstring = [[Add docstrings to this Python code:
M.python_docstring = [[You are adding docstrings to EXISTING Python code.
{{code}}
The documentation will be INSERTED into existing functions or classes.
Requirements:
- Use Google-style docstrings
- Document all functions and classes
- Include Args, Returns, Raises sections
- Add Examples where helpful
- Include type hints in docstrings
- Document only functions and classes that already exist
- Include Args, Returns, and Raises sections ONLY when applicable
- Do NOT invent parameters, return values, or exceptions
- Do NOT change the code logic
OUTPUT ONLY VALID PYTHON DOCSTRINGS. No explanations, no markdown.
]]
--- Prompt for adding LuaDoc comments
M.luadoc = [[Add LuaDoc/EmmyLua annotations to this Lua code:
--- Prompt for adding LuaDoc / EmmyLua comments
M.luadoc = [[You are adding LuaDoc / EmmyLua annotations to EXISTING Lua code.
{{code}}
The documentation will be INSERTED above existing definitions.
Requirements:
- Use ---@param for parameters
- Use ---@return for return values
- Use ---@class for table structures
- Use ---@field for class fields
- Add descriptions for all items
- Use ---@param only for existing parameters
- Use ---@return only for actual return values
- Use ---@class and ---@field only when structures already exist
- Keep descriptions accurate and minimal
- Do NOT add new code or behavior
OUTPUT ONLY VALID LUADOC / EMMYLUA COMMENTS. No explanations, no markdown.
]]
--- Prompt for adding Go documentation
M.godoc = [[Add GoDoc comments to this Go code:
M.godoc = [[You are adding GoDoc comments to EXISTING Go code.
{{code}}
The documentation will be INSERTED above existing declarations.
Requirements:
- Start comments with the name being documented
- Document all exported functions, types, and variables
- Keep comments concise but complete
- Follow Go documentation conventions
- Start each comment with the name being documented
- Document only exported functions, types, and variables
- Describe what the code does, not how it is implemented
- Do NOT invent behavior or usage
OUTPUT ONLY VALID GODoc COMMENTS. No explanations, no markdown.
]]
--- Prompt for adding README documentation
M.readme = [[Generate README documentation for this code:
--- Prompt for generating README documentation
M.readme = [[You are generating a README for an EXISTING codebase.
{{code}}
The README will be CREATED or REPLACED as a standalone document.
Include:
- Project description
- Installation instructions
- Usage examples
- API documentation
- Contributing guidelines
Requirements:
- Describe only functionality that exists in the provided code
- Include installation and usage only if they can be inferred safely
- Do NOT speculate about features or roadmap
- Keep the README concise and accurate
OUTPUT ONLY RAW README CONTENT. No markdown fences, no explanations.
]]
--- Prompt for adding inline comments
M.inline_comments = [[Add helpful inline comments to this code:
M.inline_comments = [[You are adding inline comments to EXISTING code.
{{code}}
The comments will be INSERTED without modifying code logic.
Guidelines:
- Explain complex logic
- Document non-obvious decisions
- Don't state the obvious
- Keep comments concise
- Use TODO/FIXME where appropriate
- Explain complex or non-obvious logic only
- Do NOT comment trivial or self-explanatory code
- Do NOT restate what the code already clearly says
- Do NOT introduce TODO or FIXME unless explicitly requested
OUTPUT ONLY VALID INLINE COMMENTS. No explanations, no markdown.
]]
--- Prompt for adding API documentation
M.api_docs = [[Generate API documentation for this code:
M.api_docs = [[You are generating API documentation for EXISTING code.
{{code}}
The documentation will be INSERTED or GENERATED as appropriate.
Include for each endpoint/function:
- Description
- Parameters with types
- Return value with type
- Example request/response
- Error cases
Requirements:
- Document only endpoints or functions that exist
- Describe parameters and return values accurately
- Include examples ONLY when behavior is unambiguous
- Describe error cases only if they are explicitly handled in code
- Do NOT invent request/response shapes
OUTPUT ONLY RAW API DOCUMENTATION CONTENT. No explanations, no markdown.
]]
--- Prompt for adding type definitions
M.type_definitions = [[Generate type definitions for this code:
M.type_definitions = [[You are generating type definitions for EXISTING code.
{{code}}
The types will be INSERTED or GENERATED alongside existing code.
Requirements:
- Define interfaces/types for all data structures
- Include optional properties where appropriate
- Add JSDoc/docstring descriptions
- Export all types that should be public
- Define types only for data structures that already exist
- Mark optional properties accurately
- Do NOT introduce new runtime behavior
- Match the typing style already used in the project
OUTPUT ONLY VALID TYPE DEFINITIONS. No explanations, no markdown.
]]
--- Prompt for changelog entry
M.changelog = [[Generate a changelog entry for these changes:
--- Prompt for generating a changelog entry
M.changelog = [[You are generating a changelog entry for EXISTING changes.
{{changes}}
Requirements:
- Reflect ONLY the provided changes
- Use a conventional changelog format
- Categorize changes accurately (Added, Changed, Fixed, Removed)
- Highlight breaking changes clearly if present
- Do NOT speculate or add future work
Format:
- Use conventional changelog format
- Categorize as Added/Changed/Fixed/Removed
- Be concise but descriptive
- Include breaking changes prominently
OUTPUT ONLY RAW CHANGELOG TEXT. No explanations, no markdown.
]]
--- Generic documentation prompt
M.generic = [[Add documentation to this code:
{{code}}
M.generic = [[You are adding documentation to EXISTING code.
Language: {{language}}
Requirements:
- Use appropriate documentation format for the language
- Document all public APIs
- Include parameter and return descriptions
- Add examples where helpful
- Use the correct documentation format for the language
- Document only public APIs that already exist
- Describe parameters, return values, and errors accurately
- Do NOT invent behavior, examples, or features
OUTPUT ONLY VALID DOCUMENTATION CONTENT. No explanations, no markdown.
]]
return M

View File

@@ -1,128 +1,191 @@
---@mod codetyper.prompts.refactor Refactoring prompts for Codetyper.nvim
---
--- These prompts are used for code refactoring operations.
--- These prompts are used for scoped, non-destructive refactoring operations.
local M = {}
--- Prompt for general refactoring
M.general = [[Refactor this code to improve its quality:
M.general = [[You are refactoring a SPECIFIC REGION of existing code.
{{code}}
The provided code will be REPLACED by your output.
Focus on:
- Readability
- Maintainability
- Following best practices
- Keeping the same functionality
Goals:
- Improve readability and maintainability
- Preserve ALL existing behavior
- Follow the coding style already present
- Keep changes minimal and justified
Constraints:
- Do NOT change public APIs unless explicitly required
- Do NOT introduce new dependencies
- Do NOT refactor unrelated logic
- Do NOT add comments unless explicitly requested
OUTPUT ONLY THE FULL REFACTORED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for extracting a function
M.extract_function = [[Extract a function from this code:
M.extract_function = [[You are extracting a function from an EXISTING CODE REGION.
{{code}}
The provided code will be REPLACED by your output.
The function should:
Instructions:
{{description}}
Requirements:
- Give it a meaningful name
- Include proper parameters
- Return appropriate values
Constraints:
- Preserve behavior exactly
- Extract ONLY the logic required
- Choose a name consistent with existing naming conventions
- Do NOT introduce new abstractions beyond the extracted function
- Keep parameter order and data flow explicit
OUTPUT ONLY THE FULL UPDATED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for simplifying code
M.simplify = [[Simplify this code while maintaining functionality:
M.simplify = [[You are simplifying an EXISTING CODE REGION.
{{code}}
The provided code will be REPLACED by your output.
Goals:
- Reduce complexity
- Reduce unnecessary complexity
- Remove redundancy
- Improve readability
- Keep all existing behavior
- Improve clarity without changing behavior
Constraints:
- Do NOT change function signatures unless required
- Do NOT alter control flow semantics
- Do NOT refactor unrelated logic
OUTPUT ONLY THE FULL SIMPLIFIED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for converting to async/await
M.async_await = [[Convert this code to use async/await:
M.async_await = [[You are converting an EXISTING CODE REGION to async/await syntax.
{{code}}
The provided code will be REPLACED by your output.
Requirements:
- Convert all promises to async/await
- Maintain error handling
- Keep the same functionality
- Convert promise-based logic to async/await
- Preserve existing error handling semantics
- Maintain return values and control flow
- Match existing async patterns in the file
Constraints:
- Do NOT introduce new behavior
- Do NOT change public APIs unless required
- Do NOT refactor unrelated code
OUTPUT ONLY THE FULL UPDATED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for adding error handling
M.add_error_handling = [[Add proper error handling to this code:
M.add_error_handling = [[You are adding error handling to an EXISTING CODE REGION.
{{code}}
The provided code will be REPLACED by your output.
Requirements:
- Handle all potential errors
- Use appropriate error types
- Add meaningful error messages
- Don't change core functionality
- Handle realistic failure cases for the existing logic
- Follow error-handling patterns already used in the file
- Preserve normal execution paths
Constraints:
- Do NOT change core logic
- Do NOT introduce new error types unless necessary
- Do NOT add logging unless explicitly requested
OUTPUT ONLY THE FULL UPDATED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for improving performance
M.optimize_performance = [[Optimize this code for better performance:
M.optimize_performance = [[You are optimizing an EXISTING CODE REGION for performance.
{{code}}
The provided code will be REPLACED by your output.
Focus on:
- Algorithm efficiency
- Memory usage
- Reducing unnecessary operations
- Maintaining readability
Goals:
- Improve algorithmic or operational efficiency
- Reduce unnecessary work or allocations
- Preserve readability where possible
Constraints:
- Preserve ALL existing behavior
- Do NOT introduce premature optimization
- Do NOT change public APIs
- Do NOT refactor unrelated logic
OUTPUT ONLY THE FULL OPTIMIZED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for converting to TypeScript
M.convert_to_typescript = [[Convert this JavaScript code to TypeScript:
--- Prompt for converting JavaScript to TypeScript
M.convert_to_typescript = [[You are converting an EXISTING JavaScript CODE REGION to TypeScript.
{{code}}
The provided code will be REPLACED by your output.
Requirements:
- Add proper type annotations
- Use interfaces where appropriate
- Handle null/undefined properly
- Maintain all functionality
- Add accurate type annotations
- Use interfaces or types only when they clarify intent
- Handle null and undefined explicitly where required
Constraints:
- Do NOT change runtime behavior
- Do NOT introduce types that alter semantics
- Match TypeScript style already used in the project
OUTPUT ONLY THE FULL TYPESCRIPT CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for applying design pattern
M.apply_pattern = [[Refactor this code to use the {{pattern}} pattern:
--- Prompt for applying a design pattern
M.apply_pattern = [[You are refactoring an EXISTING CODE REGION to apply the {{pattern}} pattern.
{{code}}
The provided code will be REPLACED by your output.
Requirements:
- Properly implement the pattern
- Maintain existing functionality
- Improve code organization
- Apply the pattern correctly and idiomatically
- Preserve ALL existing behavior
- Improve structure only where justified by the pattern
Constraints:
- Do NOT over-abstract
- Do NOT introduce unnecessary indirection
- Do NOT modify unrelated code
OUTPUT ONLY THE FULL UPDATED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for splitting a large function
M.split_function = [[Split this large function into smaller, focused functions:
M.split_function = [[You are splitting an EXISTING LARGE FUNCTION into smaller functions.
{{code}}
The provided code will be REPLACED by your output.
Goals:
- Single responsibility per function
- Clear function names
- Proper parameter passing
- Maintain all functionality
- Each function has a single, clear responsibility
- Names reflect existing naming conventions
- Data flow remains explicit and understandable
Constraints:
- Preserve external behavior exactly
- Do NOT change the public API unless required
- Do NOT introduce unnecessary abstraction layers
OUTPUT ONLY THE FULL UPDATED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
--- Prompt for removing code smells
M.remove_code_smells = [[Refactor this code to remove code smells:
M.remove_code_smells = [[You are refactoring an EXISTING CODE REGION to remove code smells.
{{code}}
The provided code will be REPLACED by your output.
Look for and fix:
- Long methods
- Duplicated code
- Magic numbers
- Deep nesting
- Other anti-patterns
Focus on:
- Reducing duplication
- Simplifying long or deeply nested logic
- Removing magic numbers where appropriate
Constraints:
- Preserve ALL existing behavior
- Do NOT introduce speculative refactors
- Do NOT refactor beyond the provided region
OUTPUT ONLY THE FULL CLEANED CODE FOR THIS REGION. No explanations, no markdown, no code fences.
]]
return M

View File

@@ -4,98 +4,105 @@
local M = {}
--- Base system prompt for code generation
M.code_generation = [[You are an expert code generation assistant integrated into Neovim.
Your task is to generate production-ready {{language}} code that EXACTLY matches the style of the existing file.
--- Base system prompt for code generation / modification
M.code_generation = [[You are an expert code assistant integrated into Neovim via Codetyper.nvim.
You are operating on a SPECIFIC, LIMITED REGION of an existing {{language}} file.
Your output will REPLACE that region exactly.
ABSOLUTE RULES - FOLLOW STRICTLY:
1. Output ONLY raw {{language}} code - NO explanations, NO markdown, NO code fences (```), NO comments about what you did
2. DO NOT wrap output in ``` or any markdown - just raw code
3. The output must be valid {{language}} code that can be directly inserted into the file
4. MATCH the existing code patterns in the file:
- Same indentation style (spaces/tabs)
- Same naming conventions (camelCase, snake_case, PascalCase, etc.)
- Same import/require style used in the file
- Same comment style
- Same function/class/module patterns used in the file
5. If the file has existing exports, follow the same export pattern
6. If the file uses certain libraries/frameworks, use the same ones
7. Include proper types/annotations if the language supports them and the file uses them
8. Include proper error handling following the file's patterns
1. Output ONLY raw {{language}} code NO explanations, NO markdown, NO code fences, NO meta comments
2. Do NOT include code outside the target region
3. Preserve existing structure, intent, and naming unless explicitly instructed otherwise
4. MATCH the surrounding file's conventions exactly:
- Indentation (spaces/tabs)
- Naming style (camelCase, snake_case, PascalCase, etc.)
- Import / require patterns already in use
- Error handling patterns already in use
- Type annotations only if already present in the file
5. Do NOT refactor unrelated code
6. Do NOT introduce new dependencies unless explicitly requested
7. Output must be valid {{language}} code that can be inserted directly
Language: {{language}}
File: {{filepath}}
Context:
- Language: {{language}}
- File: {{filepath}}
REMEMBER: Output ONLY valid {{language}} code. No markdown. No explanations. Just the code.
REMEMBER: Your output REPLACES a known region. Output ONLY valid {{language}} code.
]]
--- System prompt for code explanation/ask
--- System prompt for Ask / explanation mode
M.ask = [[You are a helpful coding assistant integrated into Neovim via Codetyper.nvim.
You help developers understand code, explain concepts, and answer programming questions.
Your role is to explain, analyze, or answer questions about code — NOT to modify files.
GUIDELINES:
1. Be concise but thorough in your explanations
2. Use code examples when helpful
3. Reference the provided code context in your explanations
1. Be concise, precise, and technically accurate
2. Base explanations strictly on the provided code and context
3. Use code snippets only when they clarify the explanation
4. Format responses in markdown for readability
5. If you don't know something, say so honestly
6. Break down complex concepts into understandable parts
7. Provide practical, actionable advice
5. Clearly state uncertainty if information is missing
6. Focus on practical understanding and tradeoffs
IMPORTANT: When file contents are provided, analyze them carefully and base your response on the actual code.
IMPORTANT:
- Do NOT output raw code intended for insertion
- Do NOT assume missing context
- Do NOT speculate beyond the provided information
]]
--- System prompt for refactoring
M.refactor = [[You are an expert code refactoring assistant integrated into Neovim.
Your task is to refactor {{language}} code while maintaining its functionality.
--- System prompt for scoped refactoring
M.refactor = [[You are an expert refactoring assistant integrated into Neovim via Codetyper.nvim.
You are refactoring a SPECIFIC REGION of {{language}} code.
Your output will REPLACE that region exactly.
ABSOLUTE RULES - FOLLOW STRICTLY:
1. Output ONLY the refactored {{language}} code - NO explanations, NO markdown, NO code fences (```)
2. DO NOT wrap output in ``` or any markdown - just raw code
3. Preserve ALL existing functionality
4. Improve code quality, readability, and maintainability
5. Keep the EXACT same coding style as the original file
6. Do not add new features unless explicitly requested
7. Output must be valid {{language}} code ready to replace the original
1. Output ONLY the refactored {{language}} code NO explanations, NO markdown, NO code fences
2. Preserve ALL existing behavior and external contracts
3. Improve clarity, maintainability, or structure ONLY where required
4. Keep naming, formatting, and style consistent with the original file
5. Do NOT add features or remove functionality unless explicitly instructed
6. Do NOT refactor unrelated code
Language: {{language}}
REMEMBER: Output ONLY valid {{language}} code. No markdown. No explanations.
REMEMBER: Your output replaces a known region. Output ONLY valid {{language}} code.
]]
--- System prompt for documentation
M.document = [[You are a documentation expert integrated into Neovim.
Your task is to generate documentation comments for {{language}} code.
--- System prompt for documentation generation
M.document = [[You are a documentation assistant integrated into Neovim via Codetyper.nvim.
You are generating documentation comments for EXISTING {{language}} code.
Your output will be INSERTED at a specific location.
ABSOLUTE RULES - FOLLOW STRICTLY:
1. Output ONLY the documentation comments - NO explanations, NO markdown
2. DO NOT wrap output in ``` or any markdown - just raw comments
3. Use the appropriate documentation format for {{language}}:
1. Output ONLY documentation comments NO explanations, NO markdown
2. Use the correct documentation style for {{language}}:
- JavaScript/TypeScript/JSX/TSX: JSDoc (/** ... */)
- Python: Docstrings (triple quotes)
- Lua: LuaDoc/EmmyLua (---)
- Lua: LuaDoc / EmmyLua (---)
- Go: GoDoc comments
- Rust: RustDoc (///)
- Ruby: YARD
- PHP: PHPDoc
- Java/Kotlin: Javadoc
- C/C++: Doxygen
4. Document all parameters, return values, and exceptions
5. Output must be valid comment syntax for {{language}}
3. Document parameters, return values, and errors that already exist
4. Do NOT invent behavior or undocumented side effects
Language: {{language}}
REMEMBER: Output ONLY valid {{language}} documentation comments. No markdown.
REMEMBER: Output ONLY valid {{language}} documentation comments.
]]
--- System prompt for test generation
M.test = [[You are a test generation expert integrated into Neovim.
Your task is to generate unit tests for {{language}} code.
M.test = [[You are a test generation assistant integrated into Neovim via Codetyper.nvim.
You are generating NEW unit tests for existing {{language}} code.
ABSOLUTE RULES - FOLLOW STRICTLY:
1. Output ONLY the test code - NO explanations, NO markdown, NO code fences (```)
2. DO NOT wrap output in ``` or any markdown - just raw test code
3. Use the appropriate testing framework for {{language}}:
1. Output ONLY test code NO explanations, NO markdown, NO code fences
2. Use a testing framework already present in the project when possible:
- JavaScript/TypeScript/JSX/TSX: Jest, Vitest, or Mocha
- Python: pytest or unittest
- Lua: busted or plenary
@@ -105,13 +112,13 @@ ABSOLUTE RULES - FOLLOW STRICTLY:
- PHP: PHPUnit
- Java/Kotlin: JUnit
- C/C++: Google Test or Catch2
4. Cover happy paths, edge cases, and error scenarios
5. Follow AAA pattern: Arrange, Act, Assert
6. Output must be valid {{language}} test code
3. Cover normal behavior, edge cases, and error paths
4. Follow idiomatic patterns of the chosen framework
5. Do NOT test behavior that does not exist
Language: {{language}}
REMEMBER: Output ONLY valid {{language}} test code. No markdown. No explanations.
REMEMBER: Output ONLY valid {{language}} test code.
]]
return M

48
tests/minimal_init.lua Normal file
View File

@@ -0,0 +1,48 @@
-- Minimal init.lua for running tests
-- This sets up the minimum Neovim environment needed for testing
-- Add the plugin to the runtimepath
local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h")
vim.opt.rtp:prepend(plugin_root)
-- Add plenary for testing (if available)
local plenary_path = vim.fn.expand("~/.local/share/nvim/lazy/plenary.nvim")
if vim.fn.isdirectory(plenary_path) == 1 then
vim.opt.rtp:prepend(plenary_path)
end
-- Alternative plenary paths
local alt_plenary_paths = {
vim.fn.expand("~/.local/share/nvim/site/pack/*/start/plenary.nvim"),
vim.fn.expand("~/.config/nvim/plugged/plenary.nvim"),
"/opt/homebrew/share/nvim/site/pack/packer/start/plenary.nvim",
}
for _, path in ipairs(alt_plenary_paths) do
local expanded = vim.fn.glob(path)
if expanded ~= "" and vim.fn.isdirectory(expanded) == 1 then
vim.opt.rtp:prepend(expanded)
break
end
end
-- Set up test environment
vim.opt.swapfile = false
vim.opt.backup = false
vim.opt.writebackup = false
-- Initialize codetyper with test defaults
require("codetyper").setup({
llm = {
provider = "ollama",
ollama = {
host = "http://localhost:11434",
model = "test-model",
},
},
scheduler = {
enabled = false, -- Disable scheduler during tests
},
auto_gitignore = false,
auto_open_ask = false,
})

62
tests/run_tests.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Run codetyper.nvim tests using plenary.nvim
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Running codetyper.nvim tests...${NC}"
echo "Project root: $PROJECT_ROOT"
echo ""
# Check if plenary is installed
PLENARY_PATH=""
POSSIBLE_PATHS=(
"$HOME/.local/share/nvim/lazy/plenary.nvim"
"$HOME/.local/share/nvim/site/pack/packer/start/plenary.nvim"
"$HOME/.config/nvim/plugged/plenary.nvim"
"/opt/homebrew/share/nvim/site/pack/packer/start/plenary.nvim"
)
for path in "${POSSIBLE_PATHS[@]}"; do
if [ -d "$path" ]; then
PLENARY_PATH="$path"
break
fi
done
if [ -z "$PLENARY_PATH" ]; then
echo -e "${RED}Error: plenary.nvim not found!${NC}"
echo "Please install plenary.nvim first:"
echo " - With lazy.nvim: { 'nvim-lua/plenary.nvim' }"
echo " - With packer: use 'nvim-lua/plenary.nvim'"
exit 1
fi
echo "Found plenary at: $PLENARY_PATH"
echo ""
# Run tests
if [ "$1" == "--file" ] && [ -n "$2" ]; then
# Run specific test file
echo -e "${YELLOW}Running: $2${NC}"
nvim --headless \
-u "$SCRIPT_DIR/minimal_init.lua" \
-c "PlenaryBustedFile $SCRIPT_DIR/spec/$2"
else
# Run all tests
echo -e "${YELLOW}Running all tests in spec/ directory${NC}"
nvim --headless \
-u "$SCRIPT_DIR/minimal_init.lua" \
-c "PlenaryBustedDirectory $SCRIPT_DIR/spec/ {minimal_init = '$SCRIPT_DIR/minimal_init.lua'}"
fi
echo ""
echo -e "${GREEN}Tests completed!${NC}"

View File

@@ -0,0 +1,148 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/agent/confidence.lua
describe("confidence", function()
local confidence = require("codetyper.agent.confidence")
describe("weights", function()
it("should have weights that sum to 1.0", function()
local total = 0
for _, weight in pairs(confidence.weights) do
total = total + weight
end
assert.is_near(1.0, total, 0.001)
end)
end)
describe("score", function()
it("should return 0 for empty response", function()
local score, breakdown = confidence.score("", "some prompt")
assert.equals(0, score)
assert.equals(0, breakdown.weighted_total)
end)
it("should return high score for good response", function()
local good_response = [[
function validateEmail(email)
local pattern = "^[%w%.]+@[%w%.]+%.%w+$"
return string.match(email, pattern) ~= nil
end
]]
local score, breakdown = confidence.score(good_response, "create email validator")
assert.is_true(score > 0.7)
assert.is_true(breakdown.syntax > 0.5)
end)
it("should return lower score for response with uncertainty", function()
local uncertain_response = [[
-- I'm not sure if this is correct, maybe try:
function doSomething()
-- TODO: implement this
-- placeholder code here
end
]]
local score, _ = confidence.score(uncertain_response, "implement function")
assert.is_true(score < 0.7)
end)
it("should penalize unbalanced brackets", function()
local unbalanced = [[
function test() {
if (true) {
console.log("missing bracket")
]]
local _, breakdown = confidence.score(unbalanced, "test")
assert.is_true(breakdown.syntax < 0.7)
end)
it("should penalize short responses to long prompts", function()
local long_prompt = "Create a comprehensive function that handles user authentication, " ..
"validates credentials against the database, generates JWT tokens, " ..
"handles refresh tokens, and logs all authentication attempts"
local short_response = "done"
local score, breakdown = confidence.score(short_response, long_prompt)
assert.is_true(breakdown.length < 0.5)
end)
it("should penalize repetitive code", function()
local repetitive = [[
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
console.log("test");
]]
local _, breakdown = confidence.score(repetitive, "test")
assert.is_true(breakdown.repetition < 0.7)
end)
it("should penalize truncated responses", function()
local truncated = [[
function process(data) {
const result = data.map(item => {
return {
id: item.id,
name: item...
]]
local _, breakdown = confidence.score(truncated, "test")
assert.is_true(breakdown.truncation < 1.0)
end)
end)
describe("needs_escalation", function()
it("should return true for low confidence", function()
assert.is_true(confidence.needs_escalation(0.5, 0.7))
assert.is_true(confidence.needs_escalation(0.3, 0.7))
end)
it("should return false for high confidence", function()
assert.is_false(confidence.needs_escalation(0.8, 0.7))
assert.is_false(confidence.needs_escalation(0.95, 0.7))
end)
it("should use default threshold of 0.7", function()
assert.is_true(confidence.needs_escalation(0.6))
assert.is_false(confidence.needs_escalation(0.8))
end)
end)
describe("level_name", function()
it("should return correct level names", function()
assert.equals("excellent", confidence.level_name(0.95))
assert.equals("good", confidence.level_name(0.85))
assert.equals("acceptable", confidence.level_name(0.75))
assert.equals("uncertain", confidence.level_name(0.6))
assert.equals("poor", confidence.level_name(0.3))
end)
end)
describe("format_breakdown", function()
it("should format breakdown correctly", function()
local breakdown = {
length = 0.8,
uncertainty = 0.9,
syntax = 1.0,
repetition = 0.85,
truncation = 0.95,
weighted_total = 0.9,
}
local formatted = confidence.format_breakdown(breakdown)
assert.is_true(formatted:match("len:0.80"))
assert.is_true(formatted:match("unc:0.90"))
assert.is_true(formatted:match("syn:1.00"))
end)
end)
end)

149
tests/spec/config_spec.lua Normal file
View File

@@ -0,0 +1,149 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/config.lua
describe("config", function()
local config = require("codetyper.config")
describe("defaults", function()
local defaults = config.defaults
it("should have llm configuration", function()
assert.is_table(defaults.llm)
assert.equals("claude", defaults.llm.provider)
end)
it("should have window configuration", function()
assert.is_table(defaults.window)
assert.equals(25, defaults.window.width)
assert.equals("left", defaults.window.position)
end)
it("should have pattern configuration", function()
assert.is_table(defaults.patterns)
assert.equals("/@", defaults.patterns.open_tag)
assert.equals("@/", defaults.patterns.close_tag)
end)
it("should have scheduler configuration", function()
assert.is_table(defaults.scheduler)
assert.is_boolean(defaults.scheduler.enabled)
assert.is_boolean(defaults.scheduler.ollama_scout)
assert.is_number(defaults.scheduler.escalation_threshold)
end)
it("should have claude configuration", function()
assert.is_table(defaults.llm.claude)
assert.is_truthy(defaults.llm.claude.model)
end)
it("should have openai configuration", function()
assert.is_table(defaults.llm.openai)
assert.is_truthy(defaults.llm.openai.model)
end)
it("should have gemini configuration", function()
assert.is_table(defaults.llm.gemini)
assert.is_truthy(defaults.llm.gemini.model)
end)
it("should have ollama configuration", function()
assert.is_table(defaults.llm.ollama)
assert.is_truthy(defaults.llm.ollama.host)
assert.is_truthy(defaults.llm.ollama.model)
end)
end)
describe("merge", function()
it("should merge user config with defaults", function()
local user_config = {
llm = {
provider = "openai",
},
}
local merged = config.merge(user_config)
-- User value should override
assert.equals("openai", merged.llm.provider)
-- Other defaults should be preserved
assert.equals(25, merged.window.width)
end)
it("should deep merge nested tables", function()
local user_config = {
llm = {
claude = {
model = "claude-opus-4",
},
},
}
local merged = config.merge(user_config)
-- User value should override
assert.equals("claude-opus-4", merged.llm.claude.model)
-- Provider default should be preserved
assert.equals("claude", merged.llm.provider)
end)
it("should handle empty user config", function()
local merged = config.merge({})
assert.equals("claude", merged.llm.provider)
assert.equals(25, merged.window.width)
end)
it("should handle nil user config", function()
local merged = config.merge(nil)
assert.equals("claude", merged.llm.provider)
end)
end)
describe("validate", function()
it("should return true for valid config", function()
local valid_config = config.defaults
local is_valid, err = config.validate(valid_config)
assert.is_true(is_valid)
assert.is_nil(err)
end)
it("should validate provider value", function()
local invalid_config = vim.tbl_deep_extend("force", {}, config.defaults)
invalid_config.llm.provider = "invalid_provider"
local is_valid, err = config.validate(invalid_config)
assert.is_false(is_valid)
assert.is_truthy(err)
end)
it("should validate window width range", function()
local invalid_config = vim.tbl_deep_extend("force", {}, config.defaults)
invalid_config.window.width = 101 -- Over 100%
local is_valid, err = config.validate(invalid_config)
assert.is_false(is_valid)
end)
it("should validate window position", function()
local invalid_config = vim.tbl_deep_extend("force", {}, config.defaults)
invalid_config.window.position = "center" -- Invalid
local is_valid, err = config.validate(invalid_config)
assert.is_false(is_valid)
end)
it("should validate scheduler threshold range", function()
local invalid_config = vim.tbl_deep_extend("force", {}, config.defaults)
invalid_config.scheduler.escalation_threshold = 1.5 -- Over 1.0
local is_valid, err = config.validate(invalid_config)
assert.is_false(is_valid)
end)
end)
end)

286
tests/spec/intent_spec.lua Normal file
View File

@@ -0,0 +1,286 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/agent/intent.lua
describe("intent", function()
local intent = require("codetyper.agent.intent")
describe("detect", function()
describe("complete intent", function()
it("should detect 'complete' keyword", function()
local result = intent.detect("complete this function")
assert.equals("complete", result.type)
assert.equals("replace", result.action)
end)
it("should detect 'finish' keyword", function()
local result = intent.detect("finish implementing this method")
assert.equals("complete", result.type)
end)
it("should detect 'implement' keyword", function()
local result = intent.detect("implement the sorting algorithm")
assert.equals("complete", result.type)
end)
it("should detect 'todo' keyword", function()
local result = intent.detect("fix the TODO here")
assert.equals("complete", result.type)
end)
end)
describe("refactor intent", function()
it("should detect 'refactor' keyword", function()
local result = intent.detect("refactor this messy code")
assert.equals("refactor", result.type)
assert.equals("replace", result.action)
end)
it("should detect 'rewrite' keyword", function()
local result = intent.detect("rewrite using async/await")
assert.equals("refactor", result.type)
end)
it("should detect 'simplify' keyword", function()
local result = intent.detect("simplify this logic")
assert.equals("refactor", result.type)
end)
it("should detect 'cleanup' keyword", function()
local result = intent.detect("cleanup this code")
assert.equals("refactor", result.type)
end)
end)
describe("fix intent", function()
it("should detect 'fix' keyword", function()
local result = intent.detect("fix the bug in this function")
assert.equals("fix", result.type)
assert.equals("replace", result.action)
end)
it("should detect 'debug' keyword", function()
local result = intent.detect("debug this issue")
assert.equals("fix", result.type)
end)
it("should detect 'bug' keyword", function()
local result = intent.detect("there's a bug here")
assert.equals("fix", result.type)
end)
it("should detect 'error' keyword", function()
local result = intent.detect("getting an error with this code")
assert.equals("fix", result.type)
end)
end)
describe("add intent", function()
it("should detect 'add' keyword", function()
local result = intent.detect("add input validation")
assert.equals("add", result.type)
assert.equals("insert", result.action)
end)
it("should detect 'create' keyword", function()
local result = intent.detect("create a new helper function")
assert.equals("add", result.type)
end)
it("should detect 'generate' keyword", function()
local result = intent.detect("generate a utility function")
assert.equals("add", result.type)
end)
end)
describe("document intent", function()
it("should detect 'document' keyword", function()
local result = intent.detect("document this function")
assert.equals("document", result.type)
assert.equals("replace", result.action)
end)
it("should detect 'jsdoc' keyword", function()
local result = intent.detect("add jsdoc comments")
assert.equals("document", result.type)
end)
it("should detect 'comment' keyword", function()
local result = intent.detect("add comments to explain")
assert.equals("document", result.type)
end)
end)
describe("test intent", function()
it("should detect 'test' keyword", function()
local result = intent.detect("write tests for this function")
assert.equals("test", result.type)
assert.equals("append", result.action)
end)
it("should detect 'unit test' keyword", function()
local result = intent.detect("create unit tests")
assert.equals("test", result.type)
end)
end)
describe("optimize intent", function()
it("should detect 'optimize' keyword", function()
local result = intent.detect("optimize this loop")
assert.equals("optimize", result.type)
assert.equals("replace", result.action)
end)
it("should detect 'performance' keyword", function()
local result = intent.detect("improve performance of this function")
assert.equals("optimize", result.type)
end)
it("should detect 'faster' keyword", function()
local result = intent.detect("make this faster")
assert.equals("optimize", result.type)
end)
end)
describe("explain intent", function()
it("should detect 'explain' keyword", function()
local result = intent.detect("explain what this does")
assert.equals("explain", result.type)
assert.equals("none", result.action)
end)
it("should detect 'what does' pattern", function()
local result = intent.detect("what does this function do")
assert.equals("explain", result.type)
end)
it("should detect 'how does' pattern", function()
local result = intent.detect("how does this algorithm work")
assert.equals("explain", result.type)
end)
end)
describe("default intent", function()
it("should default to 'add' for unknown prompts", function()
local result = intent.detect("make it blue")
assert.equals("add", result.type)
end)
end)
describe("scope hints", function()
it("should detect 'this function' scope hint", function()
local result = intent.detect("refactor this function")
assert.equals("function", result.scope_hint)
end)
it("should detect 'this class' scope hint", function()
local result = intent.detect("document this class")
assert.equals("class", result.scope_hint)
end)
it("should detect 'this file' scope hint", function()
local result = intent.detect("test this file")
assert.equals("file", result.scope_hint)
end)
end)
describe("confidence", function()
it("should have higher confidence with more keyword matches", function()
local result1 = intent.detect("fix")
local result2 = intent.detect("fix the bug error")
assert.is_true(result2.confidence >= result1.confidence)
end)
it("should cap confidence at 1.0", function()
local result = intent.detect("fix debug bug error issue solve")
assert.is_true(result.confidence <= 1.0)
end)
end)
end)
describe("modifies_code", function()
it("should return true for replacement intents", function()
assert.is_true(intent.modifies_code({ action = "replace" }))
end)
it("should return true for insertion intents", function()
assert.is_true(intent.modifies_code({ action = "insert" }))
end)
it("should return false for explain intent", function()
assert.is_false(intent.modifies_code({ action = "none" }))
end)
end)
describe("is_replacement", function()
it("should return true for replace action", function()
assert.is_true(intent.is_replacement({ action = "replace" }))
end)
it("should return false for insert action", function()
assert.is_false(intent.is_replacement({ action = "insert" }))
end)
end)
describe("is_insertion", function()
it("should return true for insert action", function()
assert.is_true(intent.is_insertion({ action = "insert" }))
end)
it("should return true for append action", function()
assert.is_true(intent.is_insertion({ action = "append" }))
end)
it("should return false for replace action", function()
assert.is_false(intent.is_insertion({ action = "replace" }))
end)
end)
describe("get_prompt_modifier", function()
it("should return modifier for each intent type", function()
local types = { "complete", "refactor", "fix", "add", "document", "test", "optimize", "explain" }
for _, type_name in ipairs(types) do
local modifier = intent.get_prompt_modifier({ type = type_name })
assert.is_truthy(modifier)
assert.is_true(#modifier > 0)
end
end)
it("should return add modifier for unknown type", function()
local modifier = intent.get_prompt_modifier({ type = "unknown" })
assert.is_truthy(modifier)
end)
end)
describe("format", function()
it("should format intent correctly", function()
local i = {
type = "refactor",
scope_hint = "function",
action = "replace",
confidence = 0.85,
}
local formatted = intent.format(i)
assert.is_true(formatted:match("refactor"))
assert.is_true(formatted:match("function"))
assert.is_true(formatted:match("replace"))
assert.is_true(formatted:match("0.85"))
end)
it("should handle nil scope_hint", function()
local i = {
type = "add",
scope_hint = nil,
action = "insert",
confidence = 0.5,
}
local formatted = intent.format(i)
assert.is_true(formatted:match("auto"))
end)
end)
end)

118
tests/spec/llm_spec.lua Normal file
View File

@@ -0,0 +1,118 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/llm/init.lua
describe("llm", function()
local llm = require("codetyper.llm")
describe("extract_code", function()
it("should extract code from markdown code block", function()
local response = [[
Here is the code:
```lua
function hello()
print("Hello!")
end
```
That should work.
]]
local code = llm.extract_code(response)
assert.is_true(code:match("function hello"))
assert.is_true(code:match('print%("Hello!"%)'))
assert.is_false(code:match("```"))
assert.is_false(code:match("Here is the code"))
end)
it("should extract code from generic code block", function()
local response = [[
```
const x = 1;
const y = 2;
```
]]
local code = llm.extract_code(response)
assert.is_true(code:match("const x = 1"))
end)
it("should handle multiple code blocks (return first)", function()
local response = [[
```javascript
const first = true;
```
```javascript
const second = true;
```
]]
local code = llm.extract_code(response)
assert.is_true(code:match("first"))
end)
it("should return original if no code blocks", function()
local response = "function test() return true end"
local code = llm.extract_code(response)
assert.equals(response, code)
end)
it("should handle empty code blocks", function()
local response = [[
```
```
]]
local code = llm.extract_code(response)
assert.equals("", vim.trim(code))
end)
it("should preserve indentation in extracted code", function()
local response = [[
```lua
function test()
if true then
print("nested")
end
end
```
]]
local code = llm.extract_code(response)
assert.is_true(code:match(" if true then"))
assert.is_true(code:match(" print"))
end)
end)
describe("get_client", function()
it("should return a client with generate function", function()
-- This test depends on config, but verifies interface
local client = llm.get_client()
assert.is_table(client)
assert.is_function(client.generate)
end)
end)
describe("build_system_prompt", function()
it("should include language context when provided", function()
local context = {
language = "typescript",
file_path = "/test/file.ts",
}
local prompt = llm.build_system_prompt(context)
assert.is_true(prompt:match("typescript") or prompt:match("TypeScript"))
end)
it("should work with minimal context", function()
local prompt = llm.build_system_prompt({})
assert.is_string(prompt)
assert.is_true(#prompt > 0)
end)
end)
end)

280
tests/spec/logs_spec.lua Normal file
View File

@@ -0,0 +1,280 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/agent/logs.lua
describe("logs", function()
local logs
before_each(function()
-- Reset module state before each test
package.loaded["codetyper.agent.logs"] = nil
logs = require("codetyper.agent.logs")
end)
describe("log", function()
it("should add entry to log", function()
logs.log("info", "test message")
local entries = logs.get_entries()
assert.equals(1, #entries)
assert.equals("info", entries[1].level)
assert.equals("test message", entries[1].message)
end)
it("should include timestamp", function()
logs.log("info", "test")
local entries = logs.get_entries()
assert.is_truthy(entries[1].timestamp)
assert.is_true(entries[1].timestamp:match("%d+:%d+:%d+"))
end)
it("should include optional data", function()
logs.log("info", "test", { key = "value" })
local entries = logs.get_entries()
assert.equals("value", entries[1].data.key)
end)
end)
describe("info", function()
it("should log with info level", function()
logs.info("info message")
local entries = logs.get_entries()
assert.equals("info", entries[1].level)
end)
end)
describe("debug", function()
it("should log with debug level", function()
logs.debug("debug message")
local entries = logs.get_entries()
assert.equals("debug", entries[1].level)
end)
end)
describe("error", function()
it("should log with error level", function()
logs.error("error message")
local entries = logs.get_entries()
assert.equals("error", entries[1].level)
assert.is_true(entries[1].message:match("ERROR"))
end)
end)
describe("warning", function()
it("should log with warning level", function()
logs.warning("warning message")
local entries = logs.get_entries()
assert.equals("warning", entries[1].level)
assert.is_true(entries[1].message:match("WARN"))
end)
end)
describe("request", function()
it("should log API request", function()
logs.request("claude", "claude-sonnet-4", 1000)
local entries = logs.get_entries()
assert.equals("request", entries[1].level)
assert.is_true(entries[1].message:match("CLAUDE"))
assert.is_true(entries[1].message:match("claude%-sonnet%-4"))
end)
it("should store provider info", function()
logs.request("openai", "gpt-4")
local provider, model = logs.get_provider_info()
assert.equals("openai", provider)
assert.equals("gpt-4", model)
end)
end)
describe("response", function()
it("should log API response with token counts", function()
logs.response(500, 200, "end_turn")
local entries = logs.get_entries()
assert.equals("response", entries[1].level)
assert.is_true(entries[1].message:match("500"))
assert.is_true(entries[1].message:match("200"))
end)
it("should accumulate token totals", function()
logs.response(100, 50)
logs.response(200, 100)
local prompt_tokens, response_tokens = logs.get_token_totals()
assert.equals(300, prompt_tokens)
assert.equals(150, response_tokens)
end)
end)
describe("tool", function()
it("should log tool execution", function()
logs.tool("read_file", "start", "/path/to/file.lua")
local entries = logs.get_entries()
assert.equals("tool", entries[1].level)
assert.is_true(entries[1].message:match("read_file"))
end)
it("should show correct status icons", function()
logs.tool("write_file", "success", "file created")
local entries = logs.get_entries()
assert.is_true(entries[1].message:match("OK"))
logs.tool("bash", "error", "command failed")
entries = logs.get_entries()
assert.is_true(entries[2].message:match("ERR"))
end)
end)
describe("thinking", function()
it("should log thinking step", function()
logs.thinking("Analyzing code structure")
local entries = logs.get_entries()
assert.equals("debug", entries[1].level)
assert.is_true(entries[1].message:match("> Analyzing"))
end)
end)
describe("add", function()
it("should add entry using type field", function()
logs.add({ type = "info", message = "test message" })
local entries = logs.get_entries()
assert.equals(1, #entries)
assert.equals("info", entries[1].level)
end)
it("should handle clear type", function()
logs.info("test")
logs.add({ type = "clear" })
local entries = logs.get_entries()
assert.equals(0, #entries)
end)
end)
describe("listeners", function()
it("should notify listeners on new entries", function()
local received = {}
logs.add_listener(function(entry)
table.insert(received, entry)
end)
logs.info("test message")
assert.equals(1, #received)
assert.equals("info", received[1].level)
end)
it("should support multiple listeners", function()
local count = 0
logs.add_listener(function() count = count + 1 end)
logs.add_listener(function() count = count + 1 end)
logs.info("test")
assert.equals(2, count)
end)
it("should remove listener by ID", function()
local count = 0
local id = logs.add_listener(function() count = count + 1 end)
logs.info("test1")
logs.remove_listener(id)
logs.info("test2")
assert.equals(1, count)
end)
end)
describe("clear", function()
it("should clear all entries", function()
logs.info("test1")
logs.info("test2")
logs.clear()
assert.equals(0, #logs.get_entries())
end)
it("should reset token totals", function()
logs.response(100, 50)
logs.clear()
local prompt, response = logs.get_token_totals()
assert.equals(0, prompt)
assert.equals(0, response)
end)
it("should notify listeners of clear", function()
local cleared = false
logs.add_listener(function(entry)
if entry.level == "clear" then
cleared = true
end
end)
logs.clear()
assert.is_true(cleared)
end)
end)
describe("format_entry", function()
it("should format entry for display", function()
logs.info("test message")
local entry = logs.get_entries()[1]
local formatted = logs.format_entry(entry)
assert.is_true(formatted:match("%[%d+:%d+:%d+%]"))
assert.is_true(formatted:match("i")) -- info prefix
assert.is_true(formatted:match("test message"))
end)
it("should use correct level prefixes", function()
local prefixes = {
{ level = "info", prefix = "i" },
{ level = "debug", prefix = "%." },
{ level = "request", prefix = ">" },
{ level = "response", prefix = "<" },
{ level = "tool", prefix = "T" },
{ level = "error", prefix = "!" },
}
for _, test in ipairs(prefixes) do
local entry = {
timestamp = "12:00:00",
level = test.level,
message = "test",
}
local formatted = logs.format_entry(entry)
assert.is_true(formatted:match(test.prefix), "Missing prefix for " .. test.level)
end
end)
end)
describe("estimate_tokens", function()
it("should estimate tokens from text", function()
local text = "This is a test string for token estimation."
local tokens = logs.estimate_tokens(text)
-- Rough estimate: ~4 chars per token
assert.is_true(tokens > 0)
assert.is_true(tokens < #text) -- Should be less than character count
end)
it("should handle empty string", function()
local tokens = logs.estimate_tokens("")
assert.equals(0, tokens)
end)
end)
end)

207
tests/spec/parser_spec.lua Normal file
View File

@@ -0,0 +1,207 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/parser.lua
describe("parser", function()
local parser = require("codetyper.parser")
describe("find_prompts", function()
it("should find single-line prompt", function()
local content = "/@ create a function @/"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.equals(" create a function ", prompts[1].content)
assert.equals(1, prompts[1].start_line)
assert.equals(1, prompts[1].end_line)
end)
it("should find multi-line prompt", function()
local content = [[
/@ create a function
that validates email
addresses @/
]]
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.is_true(prompts[1].content:match("validates email"))
assert.equals(2, prompts[1].start_line)
assert.equals(4, prompts[1].end_line)
end)
it("should find multiple prompts", function()
local content = [[
/@ first prompt @/
some code here
/@ second prompt @/
more code
/@ third prompt
multiline @/
]]
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(3, #prompts)
assert.equals(" first prompt ", prompts[1].content)
assert.equals(" second prompt ", prompts[2].content)
assert.is_true(prompts[3].content:match("third prompt"))
end)
it("should return empty table when no prompts found", function()
local content = "just some regular code\nno prompts here"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(0, #prompts)
end)
it("should handle prompts with special characters", function()
local content = "/@ add (function) with [brackets] @/"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.is_true(prompts[1].content:match("function"))
assert.is_true(prompts[1].content:match("brackets"))
end)
it("should handle empty prompt content", function()
local content = "/@ @/"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.equals(" ", prompts[1].content)
end)
it("should handle custom tags", function()
local content = "<!-- prompt: create button -->"
local prompts = parser.find_prompts(content, "<!-- prompt:", "-->")
assert.equals(1, #prompts)
assert.is_true(prompts[1].content:match("create button"))
end)
end)
describe("detect_prompt_type", function()
it("should detect refactor type", function()
assert.equals("refactor", parser.detect_prompt_type("refactor this code"))
assert.equals("refactor", parser.detect_prompt_type("REFACTOR the function"))
end)
it("should detect add type", function()
assert.equals("add", parser.detect_prompt_type("add a new function"))
assert.equals("add", parser.detect_prompt_type("create a component"))
assert.equals("add", parser.detect_prompt_type("implement sorting algorithm"))
end)
it("should detect document type", function()
assert.equals("document", parser.detect_prompt_type("document this function"))
assert.equals("document", parser.detect_prompt_type("add jsdoc comments"))
assert.equals("document", parser.detect_prompt_type("comment the code"))
end)
it("should detect explain type", function()
assert.equals("explain", parser.detect_prompt_type("explain this code"))
assert.equals("explain", parser.detect_prompt_type("what does this do"))
assert.equals("explain", parser.detect_prompt_type("how does this work"))
end)
it("should return generic for unknown types", function()
assert.equals("generic", parser.detect_prompt_type("do something"))
assert.equals("generic", parser.detect_prompt_type("make it better"))
end)
end)
describe("clean_prompt", function()
it("should trim whitespace", function()
assert.equals("hello", parser.clean_prompt(" hello "))
assert.equals("hello", parser.clean_prompt("\n\nhello\n\n"))
end)
it("should normalize multiple newlines", function()
local input = "line1\n\n\n\nline2"
local expected = "line1\n\nline2"
assert.equals(expected, parser.clean_prompt(input))
end)
it("should preserve single newlines", function()
local input = "line1\nline2\nline3"
assert.equals(input, parser.clean_prompt(input))
end)
end)
describe("has_closing_tag", function()
it("should return true when closing tag exists", function()
assert.is_true(parser.has_closing_tag("some text @/", "@/"))
assert.is_true(parser.has_closing_tag("@/", "@/"))
end)
it("should return false when closing tag missing", function()
assert.is_false(parser.has_closing_tag("some text", "@/"))
assert.is_false(parser.has_closing_tag("", "@/"))
end)
end)
describe("extract_file_references", function()
it("should extract single file reference", function()
local files = parser.extract_file_references("fix this @utils.ts")
assert.equals(1, #files)
assert.equals("utils.ts", files[1])
end)
it("should extract multiple file references", function()
local files = parser.extract_file_references("use @config.ts and @helpers.lua")
assert.equals(2, #files)
assert.equals("config.ts", files[1])
assert.equals("helpers.lua", files[2])
end)
it("should extract file paths with directories", function()
local files = parser.extract_file_references("check @src/utils/helpers.ts")
assert.equals(1, #files)
assert.equals("src/utils/helpers.ts", files[1])
end)
it("should NOT extract closing tag @/", function()
local files = parser.extract_file_references("fix this @/")
assert.equals(0, #files)
end)
it("should handle mixed content with closing tag", function()
local files = parser.extract_file_references("use @config.ts to fix @/")
assert.equals(1, #files)
assert.equals("config.ts", files[1])
end)
it("should return empty table when no file refs", function()
local files = parser.extract_file_references("just some text")
assert.equals(0, #files)
end)
it("should handle relative paths", function()
local files = parser.extract_file_references("check @../config.json")
assert.equals(1, #files)
assert.equals("../config.json", files[1])
end)
end)
describe("strip_file_references", function()
it("should remove single file reference", function()
local result = parser.strip_file_references("fix this @utils.ts please")
assert.equals("fix this please", result)
end)
it("should remove multiple file references", function()
local result = parser.strip_file_references("use @config.ts and @helpers.lua")
assert.equals("use and ", result)
end)
it("should NOT remove closing tag", function()
local result = parser.strip_file_references("fix this @/")
-- @/ should remain since it's the closing tag pattern
assert.is_true(result:find("@/") ~= nil)
end)
it("should handle paths with directories", function()
local result = parser.strip_file_references("check @src/utils.ts here")
assert.equals("check here", result)
end)
end)
end)

371
tests/spec/patch_spec.lua Normal file
View File

@@ -0,0 +1,371 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/agent/patch.lua
describe("patch", function()
local patch
before_each(function()
-- Reset module state before each test
package.loaded["codetyper.agent.patch"] = nil
patch = require("codetyper.agent.patch")
end)
describe("generate_id", function()
it("should generate unique IDs", function()
local id1 = patch.generate_id()
local id2 = patch.generate_id()
assert.is_not.equals(id1, id2)
assert.is_truthy(id1:match("^patch_"))
end)
end)
describe("snapshot_buffer", function()
local test_buf
before_each(function()
test_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(test_buf, 0, -1, false, {
"line 1",
"line 2",
"line 3",
"line 4",
"line 5",
})
end)
after_each(function()
if vim.api.nvim_buf_is_valid(test_buf) then
vim.api.nvim_buf_delete(test_buf, { force = true })
end
end)
it("should capture changedtick", function()
local snapshot = patch.snapshot_buffer(test_buf)
assert.is_number(snapshot.changedtick)
end)
it("should capture content hash", function()
local snapshot = patch.snapshot_buffer(test_buf)
assert.is_string(snapshot.content_hash)
assert.is_true(#snapshot.content_hash > 0)
end)
it("should snapshot specific range", function()
local snapshot = patch.snapshot_buffer(test_buf, { start_line = 2, end_line = 4 })
assert.equals(test_buf, snapshot.bufnr)
assert.is_truthy(snapshot.range)
assert.equals(2, snapshot.range.start_line)
assert.equals(4, snapshot.range.end_line)
end)
end)
describe("is_snapshot_stale", function()
local test_buf
before_each(function()
test_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(test_buf, 0, -1, false, {
"original content",
"line 2",
})
end)
after_each(function()
if vim.api.nvim_buf_is_valid(test_buf) then
vim.api.nvim_buf_delete(test_buf, { force = true })
end
end)
it("should return false for unchanged buffer", function()
local snapshot = patch.snapshot_buffer(test_buf)
local is_stale, reason = patch.is_snapshot_stale(snapshot)
assert.is_false(is_stale)
assert.is_nil(reason)
end)
it("should return true when content changes", function()
local snapshot = patch.snapshot_buffer(test_buf)
-- Modify buffer
vim.api.nvim_buf_set_lines(test_buf, 0, 1, false, { "modified content" })
local is_stale, reason = patch.is_snapshot_stale(snapshot)
assert.is_true(is_stale)
assert.equals("content_changed", reason)
end)
it("should return true for invalid buffer", function()
local snapshot = patch.snapshot_buffer(test_buf)
-- Delete buffer
vim.api.nvim_buf_delete(test_buf, { force = true })
local is_stale, reason = patch.is_snapshot_stale(snapshot)
assert.is_true(is_stale)
assert.equals("buffer_invalid", reason)
end)
end)
describe("queue_patch", function()
it("should add patch to queue", function()
local p = {
event_id = "test_event",
target_bufnr = 1,
target_path = "/test/file.lua",
original_snapshot = {
bufnr = 1,
changedtick = 0,
content_hash = "abc123",
},
generated_code = "function test() end",
confidence = 0.9,
}
local queued = patch.queue_patch(p)
assert.is_truthy(queued.id)
assert.equals("pending", queued.status)
local pending = patch.get_pending()
assert.equals(1, #pending)
end)
it("should set default status", function()
local p = {
event_id = "test",
generated_code = "code",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
}
local queued = patch.queue_patch(p)
assert.equals("pending", queued.status)
end)
end)
describe("get", function()
it("should return patch by ID", function()
local p = patch.queue_patch({
event_id = "test",
generated_code = "code",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
local found = patch.get(p.id)
assert.is_not_nil(found)
assert.equals(p.id, found.id)
end)
it("should return nil for unknown ID", function()
local found = patch.get("unknown_id")
assert.is_nil(found)
end)
end)
describe("mark_applied", function()
it("should mark patch as applied", function()
local p = patch.queue_patch({
event_id = "test",
generated_code = "code",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
local success = patch.mark_applied(p.id)
assert.is_true(success)
assert.equals("applied", patch.get(p.id).status)
assert.is_truthy(patch.get(p.id).applied_at)
end)
end)
describe("mark_stale", function()
it("should mark patch as stale with reason", function()
local p = patch.queue_patch({
event_id = "test",
generated_code = "code",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
local success = patch.mark_stale(p.id, "content_changed")
assert.is_true(success)
assert.equals("stale", patch.get(p.id).status)
assert.equals("content_changed", patch.get(p.id).stale_reason)
end)
end)
describe("stats", function()
it("should return correct statistics", function()
local p1 = patch.queue_patch({
event_id = "test1",
generated_code = "code1",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
patch.queue_patch({
event_id = "test2",
generated_code = "code2",
confidence = 0.9,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "y" },
})
patch.mark_applied(p1.id)
local stats = patch.stats()
assert.equals(2, stats.total)
assert.equals(1, stats.pending)
assert.equals(1, stats.applied)
end)
end)
describe("get_for_event", function()
it("should return patches for specific event", function()
patch.queue_patch({
event_id = "event_a",
generated_code = "code1",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
patch.queue_patch({
event_id = "event_b",
generated_code = "code2",
confidence = 0.9,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "y" },
})
patch.queue_patch({
event_id = "event_a",
generated_code = "code3",
confidence = 0.7,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "z" },
})
local event_a_patches = patch.get_for_event("event_a")
assert.equals(2, #event_a_patches)
end)
end)
describe("clear", function()
it("should clear all patches", function()
patch.queue_patch({
event_id = "test",
generated_code = "code",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
patch.clear()
assert.equals(0, #patch.get_pending())
assert.equals(0, patch.stats().total)
end)
end)
describe("cancel_for_buffer", function()
it("should cancel patches for specific buffer", function()
patch.queue_patch({
event_id = "test1",
target_bufnr = 1,
generated_code = "code1",
confidence = 0.8,
original_snapshot = { bufnr = 1, changedtick = 0, content_hash = "x" },
})
patch.queue_patch({
event_id = "test2",
target_bufnr = 2,
generated_code = "code2",
confidence = 0.9,
original_snapshot = { bufnr = 2, changedtick = 0, content_hash = "y" },
})
local cancelled = patch.cancel_for_buffer(1)
assert.equals(1, cancelled)
assert.equals(1, #patch.get_pending())
end)
end)
describe("create_from_event", function()
it("should create patch with replace strategy for complete intent", function()
local event = {
id = "evt_123",
target_path = "/tmp/test.lua",
bufnr = 1,
range = { start_line = 5, end_line = 10 },
scope_range = { start_line = 3, end_line = 12 },
scope = { type = "function", name = "test_fn" },
intent = {
type = "complete",
action = "replace",
confidence = 0.9,
keywords = {},
},
}
local p = patch.create_from_event(event, "function code", 0.9)
assert.equals("replace", p.injection_strategy)
assert.is_truthy(p.injection_range)
assert.equals(3, p.injection_range.start_line)
assert.equals(12, p.injection_range.end_line)
end)
it("should create patch with append strategy for add intent", function()
local event = {
id = "evt_456",
target_path = "/tmp/test.lua",
bufnr = 1,
range = { start_line = 5, end_line = 10 },
intent = {
type = "add",
action = "append",
confidence = 0.8,
keywords = {},
},
}
local p = patch.create_from_event(event, "new function", 0.8)
assert.equals("append", p.injection_strategy)
end)
it("should create patch with insert strategy for insert action", function()
local event = {
id = "evt_789",
target_path = "/tmp/test.lua",
bufnr = 1,
range = { start_line = 5, end_line = 10 },
intent = {
type = "add",
action = "insert",
confidence = 0.8,
keywords = {},
},
}
local p = patch.create_from_event(event, "inserted code", 0.8)
assert.equals("insert", p.injection_strategy)
assert.is_truthy(p.injection_range)
assert.equals(5, p.injection_range.start_line)
end)
end)
end)

View File

@@ -0,0 +1,276 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/preferences.lua
-- Note: UI tests (floating window) are skipped per testing guidelines
describe("preferences", function()
local preferences
local utils
-- Mock cwd for testing
local test_cwd = "/tmp/codetyper_test_prefs"
before_each(function()
-- Reset modules
package.loaded["codetyper.preferences"] = nil
package.loaded["codetyper.utils"] = nil
preferences = require("codetyper.preferences")
utils = require("codetyper.utils")
-- Clear cache before each test
preferences.clear_cache()
-- Create test directory
vim.fn.mkdir(test_cwd, "p")
vim.fn.mkdir(test_cwd .. "/.coder", "p")
-- Mock getcwd to return test directory
vim.fn.getcwd = function()
return test_cwd
end
end)
after_each(function()
-- Clean up test directory
vim.fn.delete(test_cwd, "rf")
end)
describe("load", function()
it("should return defaults when no preferences file exists", function()
local prefs = preferences.load()
assert.is_table(prefs)
assert.is_nil(prefs.auto_process)
assert.is_false(prefs.asked_auto_process)
end)
it("should load preferences from file", function()
-- Create preferences file
local path = test_cwd .. "/.coder/preferences.json"
utils.write_file(path, '{"auto_process":true,"asked_auto_process":true}')
local prefs = preferences.load()
assert.is_true(prefs.auto_process)
assert.is_true(prefs.asked_auto_process)
end)
it("should merge file preferences with defaults", function()
-- Create partial preferences file
local path = test_cwd .. "/.coder/preferences.json"
utils.write_file(path, '{"auto_process":false}')
local prefs = preferences.load()
assert.is_false(prefs.auto_process)
-- Default for asked_auto_process should be preserved
assert.is_false(prefs.asked_auto_process)
end)
it("should cache preferences", function()
local prefs1 = preferences.load()
prefs1.test_value = "cached"
-- Load again - should get cached version
local prefs2 = preferences.load()
assert.equals("cached", prefs2.test_value)
end)
it("should handle invalid JSON gracefully", function()
local path = test_cwd .. "/.coder/preferences.json"
utils.write_file(path, "not valid json {{{")
local prefs = preferences.load()
-- Should return defaults
assert.is_table(prefs)
assert.is_nil(prefs.auto_process)
end)
end)
describe("save", function()
it("should save preferences to file", function()
local prefs = {
auto_process = true,
asked_auto_process = true,
}
preferences.save(prefs)
-- Verify file was created
local path = test_cwd .. "/.coder/preferences.json"
local content = utils.read_file(path)
assert.is_truthy(content)
local decoded = vim.json.decode(content)
assert.is_true(decoded.auto_process)
assert.is_true(decoded.asked_auto_process)
end)
it("should update cache after save", function()
local prefs = {
auto_process = true,
asked_auto_process = true,
}
preferences.save(prefs)
-- Load should return the saved values from cache
local loaded = preferences.load()
assert.is_true(loaded.auto_process)
end)
it("should create .coder directory if it does not exist", function()
-- Remove .coder directory
vim.fn.delete(test_cwd .. "/.coder", "rf")
local prefs = { auto_process = false }
preferences.save(prefs)
-- Directory should be created
assert.equals(1, vim.fn.isdirectory(test_cwd .. "/.coder"))
end)
end)
describe("get", function()
it("should get a specific preference value", function()
local path = test_cwd .. "/.coder/preferences.json"
utils.write_file(path, '{"auto_process":true}')
local value = preferences.get("auto_process")
assert.is_true(value)
end)
it("should return nil for non-existent key", function()
local value = preferences.get("non_existent_key")
assert.is_nil(value)
end)
end)
describe("set", function()
it("should set a specific preference value", function()
preferences.set("auto_process", true)
local value = preferences.get("auto_process")
assert.is_true(value)
end)
it("should persist the value to file", function()
preferences.set("auto_process", false)
-- Clear cache and reload
preferences.clear_cache()
local value = preferences.get("auto_process")
assert.is_false(value)
end)
end)
describe("is_auto_process_enabled", function()
it("should return nil when not set", function()
local result = preferences.is_auto_process_enabled()
assert.is_nil(result)
end)
it("should return true when enabled", function()
preferences.set("auto_process", true)
local result = preferences.is_auto_process_enabled()
assert.is_true(result)
end)
it("should return false when disabled", function()
preferences.set("auto_process", false)
local result = preferences.is_auto_process_enabled()
assert.is_false(result)
end)
end)
describe("set_auto_process", function()
it("should set auto_process to true", function()
preferences.set_auto_process(true)
assert.is_true(preferences.is_auto_process_enabled())
assert.is_true(preferences.has_asked_auto_process())
end)
it("should set auto_process to false", function()
preferences.set_auto_process(false)
assert.is_false(preferences.is_auto_process_enabled())
assert.is_true(preferences.has_asked_auto_process())
end)
it("should also set asked_auto_process to true", function()
preferences.set_auto_process(true)
assert.is_true(preferences.has_asked_auto_process())
end)
end)
describe("has_asked_auto_process", function()
it("should return false when not asked", function()
local result = preferences.has_asked_auto_process()
assert.is_false(result)
end)
it("should return true after setting auto_process", function()
preferences.set_auto_process(true)
local result = preferences.has_asked_auto_process()
assert.is_true(result)
end)
end)
describe("clear_cache", function()
it("should clear cached preferences", function()
-- Load to populate cache
local prefs = preferences.load()
prefs.test_marker = "before_clear"
-- Clear cache
preferences.clear_cache()
-- Load again - should not have the marker
local prefs_after = preferences.load()
assert.is_nil(prefs_after.test_marker)
end)
end)
describe("toggle_auto_process", function()
it("should toggle from nil to true", function()
-- Initially nil
assert.is_nil(preferences.is_auto_process_enabled())
preferences.toggle_auto_process()
-- Should be true (not nil becomes true)
assert.is_true(preferences.is_auto_process_enabled())
end)
it("should toggle from true to false", function()
preferences.set_auto_process(true)
preferences.toggle_auto_process()
assert.is_false(preferences.is_auto_process_enabled())
end)
it("should toggle from false to true", function()
preferences.set_auto_process(false)
preferences.toggle_auto_process()
assert.is_true(preferences.is_auto_process_enabled())
end)
end)
end)

332
tests/spec/queue_spec.lua Normal file
View File

@@ -0,0 +1,332 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/agent/queue.lua
describe("queue", function()
local queue
before_each(function()
-- Reset module state before each test
package.loaded["codetyper.agent.queue"] = nil
queue = require("codetyper.agent.queue")
end)
describe("generate_id", function()
it("should generate unique IDs", function()
local id1 = queue.generate_id()
local id2 = queue.generate_id()
assert.is_not.equals(id1, id2)
assert.is_true(id1:match("^evt_"))
assert.is_true(id2:match("^evt_"))
end)
end)
describe("hash_content", function()
it("should generate consistent hashes", function()
local content = "test content"
local hash1 = queue.hash_content(content)
local hash2 = queue.hash_content(content)
assert.equals(hash1, hash2)
end)
it("should generate different hashes for different content", function()
local hash1 = queue.hash_content("content A")
local hash2 = queue.hash_content("content B")
assert.is_not.equals(hash1, hash2)
end)
end)
describe("enqueue", function()
it("should add event to queue", function()
local event = {
bufnr = 1,
prompt_content = "test prompt",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
}
local enqueued = queue.enqueue(event)
assert.is_not_nil(enqueued.id)
assert.equals("pending", enqueued.status)
assert.equals(1, queue.size())
end)
it("should set default priority to 2", function()
local event = {
bufnr = 1,
prompt_content = "test prompt",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
}
local enqueued = queue.enqueue(event)
assert.equals(2, enqueued.priority)
end)
it("should maintain priority order", function()
queue.enqueue({
bufnr = 1,
prompt_content = "low priority",
target_path = "/test/file.lua",
priority = 3,
range = { start_line = 1, end_line = 1 },
})
queue.enqueue({
bufnr = 1,
prompt_content = "high priority",
target_path = "/test/file.lua",
priority = 1,
range = { start_line = 1, end_line = 1 },
})
local first = queue.dequeue()
assert.equals("high priority", first.prompt_content)
end)
it("should generate content hash automatically", function()
local event = {
bufnr = 1,
prompt_content = "test prompt",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
}
local enqueued = queue.enqueue(event)
assert.is_not_nil(enqueued.content_hash)
end)
end)
describe("dequeue", function()
it("should return nil when queue is empty", function()
local event = queue.dequeue()
assert.is_nil(event)
end)
it("should return and mark event as processing", function()
queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
local event = queue.dequeue()
assert.is_not_nil(event)
assert.equals("processing", event.status)
end)
it("should skip non-pending events", function()
local evt1 = queue.enqueue({
bufnr = 1,
prompt_content = "first",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.enqueue({
bufnr = 1,
prompt_content = "second",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
-- Mark first as completed
queue.complete(evt1.id)
local event = queue.dequeue()
assert.equals("second", event.prompt_content)
end)
end)
describe("peek", function()
it("should return next pending without removing", function()
queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
local event1 = queue.peek()
local event2 = queue.peek()
assert.is_not_nil(event1)
assert.equals(event1.id, event2.id)
assert.equals("pending", event1.status)
end)
end)
describe("get", function()
it("should return event by ID", function()
local enqueued = queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
local event = queue.get(enqueued.id)
assert.is_not_nil(event)
assert.equals(enqueued.id, event.id)
end)
it("should return nil for unknown ID", function()
local event = queue.get("unknown_id")
assert.is_nil(event)
end)
end)
describe("update_status", function()
it("should update event status", function()
local enqueued = queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
local success = queue.update_status(enqueued.id, "completed")
assert.is_true(success)
assert.equals("completed", queue.get(enqueued.id).status)
end)
it("should return false for unknown ID", function()
local success = queue.update_status("unknown_id", "completed")
assert.is_false(success)
end)
it("should merge extra fields", function()
local enqueued = queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.update_status(enqueued.id, "completed", { error = "test error" })
local event = queue.get(enqueued.id)
assert.equals("test error", event.error)
end)
end)
describe("cancel_for_buffer", function()
it("should cancel all pending events for buffer", function()
queue.enqueue({
bufnr = 1,
prompt_content = "buffer 1 - first",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.enqueue({
bufnr = 1,
prompt_content = "buffer 1 - second",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.enqueue({
bufnr = 2,
prompt_content = "buffer 2",
target_path = "/test/file2.lua",
range = { start_line = 1, end_line = 1 },
})
local cancelled = queue.cancel_for_buffer(1)
assert.equals(2, cancelled)
assert.equals(1, queue.pending_count())
end)
end)
describe("stats", function()
it("should return correct statistics", function()
queue.enqueue({
bufnr = 1,
prompt_content = "pending",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
local evt = queue.enqueue({
bufnr = 1,
prompt_content = "to complete",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.complete(evt.id)
local stats = queue.stats()
assert.equals(2, stats.total)
assert.equals(1, stats.pending)
assert.equals(1, stats.completed)
end)
end)
describe("clear", function()
it("should clear all events", function()
queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.clear()
assert.equals(0, queue.size())
end)
it("should clear only specified status", function()
local evt = queue.enqueue({
bufnr = 1,
prompt_content = "to complete",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.complete(evt.id)
queue.enqueue({
bufnr = 1,
prompt_content = "pending",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
queue.clear("completed")
assert.equals(1, queue.size())
assert.equals(1, queue.pending_count())
end)
end)
describe("listeners", function()
it("should notify listeners on enqueue", function()
local notifications = {}
queue.add_listener(function(event_type, event, size)
table.insert(notifications, { type = event_type, event = event, size = size })
end)
queue.enqueue({
bufnr = 1,
prompt_content = "test",
target_path = "/test/file.lua",
range = { start_line = 1, end_line = 1 },
})
assert.equals(1, #notifications)
assert.equals("enqueue", notifications[1].type)
end)
end)
end)

139
tests/spec/utils_spec.lua Normal file
View File

@@ -0,0 +1,139 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/utils.lua
describe("utils", function()
local utils = require("codetyper.utils")
describe("is_coder_file", function()
it("should return true for coder files", function()
assert.is_true(utils.is_coder_file("index.coder.ts"))
assert.is_true(utils.is_coder_file("main.coder.lua"))
assert.is_true(utils.is_coder_file("/path/to/file.coder.py"))
end)
it("should return false for regular files", function()
assert.is_false(utils.is_coder_file("index.ts"))
assert.is_false(utils.is_coder_file("main.lua"))
assert.is_false(utils.is_coder_file("coder.ts"))
end)
end)
describe("get_target_path", function()
it("should convert coder path to target path", function()
assert.equals("index.ts", utils.get_target_path("index.coder.ts"))
assert.equals("main.lua", utils.get_target_path("main.coder.lua"))
assert.equals("/path/to/file.py", utils.get_target_path("/path/to/file.coder.py"))
end)
end)
describe("get_coder_path", function()
it("should convert target path to coder path", function()
assert.equals("index.coder.ts", utils.get_coder_path("index.ts"))
assert.equals("main.coder.lua", utils.get_coder_path("main.lua"))
end)
it("should preserve directory path", function()
local result = utils.get_coder_path("/path/to/file.py")
assert.is_truthy(result:match("/path/to/"))
assert.is_truthy(result:match("file%.coder%.py"))
end)
end)
describe("escape_pattern", function()
it("should escape special pattern characters", function()
-- Note: @ is NOT a special Lua pattern character
-- Special chars are: ( ) . % + - * ? [ ] ^ $
assert.equals("/@", utils.escape_pattern("/@"))
assert.equals("@/", utils.escape_pattern("@/"))
assert.equals("hello%.world", utils.escape_pattern("hello.world"))
assert.equals("test%+pattern", utils.escape_pattern("test+pattern"))
end)
it("should handle multiple special characters", function()
local input = "(test)[pattern]"
local escaped = utils.escape_pattern(input)
-- Use string.find with plain=true to avoid pattern interpretation
assert.is_truthy(string.find(escaped, "%(", 1, true))
assert.is_truthy(string.find(escaped, "%)", 1, true))
assert.is_truthy(string.find(escaped, "%[", 1, true))
assert.is_truthy(string.find(escaped, "%]", 1, true))
end)
end)
describe("file operations", function()
local test_dir
local test_file
before_each(function()
test_dir = vim.fn.tempname()
utils.ensure_dir(test_dir)
test_file = test_dir .. "/test.txt"
end)
after_each(function()
vim.fn.delete(test_dir, "rf")
end)
describe("ensure_dir", function()
it("should create directory", function()
local new_dir = test_dir .. "/subdir"
local result = utils.ensure_dir(new_dir)
assert.is_true(result)
assert.equals(1, vim.fn.isdirectory(new_dir))
end)
it("should return true for existing directory", function()
local result = utils.ensure_dir(test_dir)
assert.is_true(result)
end)
end)
describe("write_file", function()
it("should write content to file", function()
local result = utils.write_file(test_file, "test content")
assert.is_true(result)
assert.is_true(utils.file_exists(test_file))
end)
end)
describe("read_file", function()
it("should read file content", function()
utils.write_file(test_file, "test content")
local content = utils.read_file(test_file)
assert.equals("test content", content)
end)
it("should return nil for non-existent file", function()
local content = utils.read_file("/non/existent/file.txt")
assert.is_nil(content)
end)
end)
describe("file_exists", function()
it("should return true for existing file", function()
utils.write_file(test_file, "content")
assert.is_true(utils.file_exists(test_file))
end)
it("should return false for non-existent file", function()
assert.is_false(utils.file_exists("/non/existent/file.txt"))
end)
end)
end)
describe("get_filetype", function()
it("should return filetype for buffer", function()
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].filetype = "lua"
local ft = utils.get_filetype(buf)
assert.equals("lua", ft)
vim.api.nvim_buf_delete(buf, { force = true })
end)
end)
end)

269
tests/spec/worker_spec.lua Normal file
View File

@@ -0,0 +1,269 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/agent/worker.lua response cleaning
-- We need to test the clean_response function
-- Since it's local, we'll create a test module that exposes it
describe("worker response cleaning", function()
-- Mock the clean_response function behavior directly
local function clean_response(response)
if not response then
return ""
end
local cleaned = response
-- Remove the original prompt tags /@ ... @/ if they appear in output
-- Use [%s%S] to match any character including newlines
cleaned = cleaned:gsub("/@[%s%S]-@/", "")
-- Try to extract code from markdown code blocks
local code_block = cleaned:match("```[%w]*\n(.-)\n```")
if not code_block then
code_block = cleaned:match("```[%w]*(.-)\n```")
end
if not code_block then
code_block = cleaned:match("```(.-)```")
end
if code_block then
cleaned = code_block
else
local explanation_starts = {
"^[Ii]'m sorry.-\n",
"^[Ii] apologize.-\n",
"^[Hh]ere is.-:\n",
"^[Hh]ere's.-:\n",
"^[Tt]his is.-:\n",
"^[Bb]ased on.-:\n",
"^[Ss]ure.-:\n",
"^[Oo][Kk].-:\n",
"^[Cc]ertainly.-:\n",
}
for _, pattern in ipairs(explanation_starts) do
cleaned = cleaned:gsub(pattern, "")
end
local explanation_ends = {
"\n[Tt]his code.-$",
"\n[Tt]his function.-$",
"\n[Tt]his is a.-$",
"\n[Ii] hope.-$",
"\n[Ll]et me know.-$",
"\n[Ff]eel free.-$",
"\n[Nn]ote:.-$",
"\n[Pp]lease replace.-$",
"\n[Pp]lease note.-$",
"\n[Yy]ou might want.-$",
"\n[Yy]ou may want.-$",
"\n[Mm]ake sure.-$",
"\n[Aa]lso,.-$",
"\n[Rr]emember.-$",
}
for _, pattern in ipairs(explanation_ends) do
cleaned = cleaned:gsub(pattern, "")
end
end
cleaned = cleaned:gsub("^```[%w]*\n?", "")
cleaned = cleaned:gsub("\n?```$", "")
cleaned = cleaned:match("^%s*(.-)%s*$") or cleaned
return cleaned
end
describe("clean_response", function()
it("should extract code from markdown code blocks", function()
local response = [[```java
public void test() {
System.out.println("Hello");
}
```]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("public void test") ~= nil)
assert.is_true(cleaned:find("```") == nil)
end)
it("should handle code blocks without language", function()
local response = [[```
function test()
print("hello")
end
```]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("function test") ~= nil)
assert.is_true(cleaned:find("```") == nil)
end)
it("should remove single-line prompt tags from response", function()
local response = [[/@ create a function @/
function test() end]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("/@") == nil)
assert.is_true(cleaned:find("@/") == nil)
assert.is_true(cleaned:find("function test") ~= nil)
end)
it("should remove multiline prompt tags from response", function()
local response = [[function test() end
/@
create a function
that does something
@/
function another() end]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("/@") == nil)
assert.is_true(cleaned:find("@/") == nil)
assert.is_true(cleaned:find("function test") ~= nil)
assert.is_true(cleaned:find("function another") ~= nil)
end)
it("should remove multiple prompt tags from response", function()
local response = [[function test() end
/@ first prompt @/
/@ second
multiline prompt @/
function another() end]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("/@") == nil)
assert.is_true(cleaned:find("@/") == nil)
assert.is_true(cleaned:find("function test") ~= nil)
assert.is_true(cleaned:find("function another") ~= nil)
end)
it("should remove apology prefixes", function()
local response = [[I'm sorry for any confusion.
Here is the code:
function test() end]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("sorry") == nil or cleaned:find("function test") ~= nil)
end)
it("should remove trailing explanations", function()
local response = [[function test() end
This code does something useful.]]
local cleaned = clean_response(response)
-- The ending pattern should be removed
assert.is_true(cleaned:find("function test") ~= nil)
end)
it("should handle empty response", function()
local cleaned = clean_response("")
assert.equals("", cleaned)
end)
it("should handle nil response", function()
local cleaned = clean_response(nil)
assert.equals("", cleaned)
end)
it("should preserve clean code", function()
local response = [[function test()
return true
end]]
local cleaned = clean_response(response)
assert.equals(response, cleaned)
end)
it("should handle complex markdown with explanation", function()
local response = [[Here is the implementation:
```lua
local function validate(input)
if not input then
return false
end
return true
end
```
Let me know if you need any changes.]]
local cleaned = clean_response(response)
assert.is_true(cleaned:find("local function validate") ~= nil)
assert.is_true(cleaned:find("```") == nil)
assert.is_true(cleaned:find("Let me know") == nil)
end)
end)
describe("needs_more_context detection", function()
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 function needs_more_context(response)
if not response then
return false
end
-- If response has substantial code, don't ask for context
local lines = vim.split(response, "\n")
local code_lines = 0
for _, line in ipairs(lines) do
if line:match("[{}();=]") or line:match("function") or line:match("def ")
or line:match("class ") or line:match("return ") or line:match("import ")
or line:match("public ") or line:match("private ") or line:match("local ") then
code_lines = code_lines + 1
end
end
if code_lines >= 3 then
return false
end
local lower = response:lower()
for _, pattern in ipairs(context_needed_patterns) do
if lower:match(pattern) then
return true
end
end
return false
end
it("should detect context needed phrases at start", function()
assert.is_true(needs_more_context("I need more context to help you"))
assert.is_true(needs_more_context("Could you provide more context?"))
assert.is_true(needs_more_context("Can you clarify what you want?"))
assert.is_true(needs_more_context("I'm sorry, but I need more information to help"))
end)
it("should not trigger on normal responses", function()
assert.is_false(needs_more_context("Here is your code"))
assert.is_false(needs_more_context("function test() end"))
assert.is_false(needs_more_context("The implementation is complete"))
end)
it("should not trigger when response has substantial code", function()
local response_with_code = [[Here is the code:
function test() {
return true;
}
function another() {
return false;
}]]
assert.is_false(needs_more_context(response_with_code))
end)
it("should not trigger on code with explanatory text", function()
local response = [[public void test() {
System.out.println("Hello");
}
Please replace the connection string with your actual database.]]
assert.is_false(needs_more_context(response))
end)
it("should handle nil response", function()
assert.is_false(needs_more_context(nil))
end)
end)
end)