diff --git a/CHANGELOG.md b/CHANGELOG.md index 3214077..3234e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `ctt` (cursor/visual), `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 + - `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 diff --git a/README.md b/README.md index 528e6d6..ac0b3a1 100644 --- a/README.md +++ b/README.md @@ -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,7 @@ - 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 --- @@ -48,16 +55,16 @@ ```lua { "cargdev/codetyper.nvim", - cmd = { "Coder", "CoderOpen", "CoderToggle" }, + cmd = { "Coder", "CoderOpen", "CoderToggle", "CoderAgent" }, keys = { { "co", "Coder open", desc = "Coder: Open" }, { "ct", "Coder toggle", desc = "Coder: Toggle" }, - { "cp", "Coder process", desc = "Coder: Process" }, + { "ca", "CoderAgentToggle", desc = "Coder: Agent" }, }, config = function() require("codetyper").setup({ llm = { - provider = "claude", -- or "ollama" + provider = "claude", -- or "openai", "gemini", "copilot", "ollama" }, }) end, @@ -93,8 +100,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 +108,66 @@ 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 + }, }) ``` @@ -141,334 +175,238 @@ 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 | +| `refactor`, `rewrite` | Refactor | Replaces code | +| `add`, `create`, `implement` | Add | Inserts new code | +| `document`, `comment` | Document | Adds documentation | +| `explain`, `what`, `how` | Explain | Shows explanation only | --- -### Prompt Examples +## 🤖 Agent Mode -#### Creating New Functions +The Agent mode provides an autonomous coding assistant with tool access: -```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 @/ -``` +### Available Tools -#### Refactoring Code +- **read_file**: Read file contents +- **edit_file**: Edit files with find/replace +- **write_file**: Create or overwrite files +- **bash**: Execute shell commands -```typescript -/@ Refactor the handleSubmit function to: -- Use async/await instead of .then() -- Add proper TypeScript types -- Extract validation logic into separate function @/ -``` +### Using Agent Mode -#### Adding Documentation +1. Open the agent panel: `:CoderAgent` or `ca` +2. Describe what you want to accomplish +3. The agent will use tools to complete the task +4. Review changes before they're applied -```typescript -/@ Add JSDoc documentation to all exported functions -including @param, @returns, and @example tags @/ -``` +### Agent Keymaps -#### Implementing Patterns +| Key | Description | +|-----|-------------| +| `` | Submit message | +| `Ctrl+c` | Stop agent execution | +| `q` | Close agent panel | -```typescript -/@ Implement the singleton pattern for DatabaseConnection class -with lazy initialization and thread safety @/ -``` +--- -#### Adding Tests +## ⌨️ Keymaps -```typescript -/@ Create unit tests for the calculateTotal function -using Jest, cover edge cases: -- Empty array -- Negative numbers -- Large numbers @/ +### Default Keymaps (auto-configured) + +| Key | Mode | Description | +|-----|------|-------------| +| `ctt` | Normal | Transform tag at cursor | +| `ctt` | Visual | Transform selected tags | +| `ctT` | Normal | Transform all tags in file | +| `ca` | Normal | Toggle Agent panel | +| `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", "co", "Coder open", { desc = "Coder: Open" }) +map("n", "cc", "Coder close", { desc = "Coder: Close" }) +map("n", "ct", "Coder toggle", { desc = "Coder: Toggle" }) +map("n", "cp", "Coder process", { desc = "Coder: Process" }) +map("n", "cs", "Coder status", { desc = "Coder: Status" }) ``` --- -## 🏗️ 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(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 +415,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", "co", "Coder open", { desc = "Coder: Open view" }) -map("n", "cc", "Coder close", { desc = "Coder: Close view" }) -map("n", "ct", "Coder toggle", { desc = "Coder: Toggle view" }) -map("n", "cp", "Coder process", { desc = "Coder: Process prompt" }) -map("n", "cs", "Coder status", { desc = "Coder: Show status" }) -map("n", "cf", "Coder focus", { desc = "Coder: Switch focus" }) -map("n", "cv", "Coder tree-view", { desc = "Coder: View tree" }) - --- Ask panel -map("n", "ca", "Coder ask", { desc = "Coder: Open Ask" }) -map("n", "cA", "Coder ask-toggle", { desc = "Coder: Toggle Ask" }) -map("n", "cx", "Coder ask-clear", { desc = "Coder: Clear Ask" }) -``` - -Or with [which-key.nvim](https://github.com/folke/which-key.nvim): - -```lua -local wk = require("which-key") -wk.register({ - ["c"] = { - name = "+coder", - o = { "Coder open", "Open view" }, - c = { "Coder close", "Close view" }, - t = { "Coder toggle", "Toggle view" }, - p = { "Coder process", "Process prompt" }, - s = { "Coder status", "Show status" }, - f = { "Coder focus", "Switch focus" }, - v = { "Coder tree-view", "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 +432,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 --- diff --git a/doc/codetyper.txt b/doc/codetyper.txt index e4869d9..d87d7b5 100644 --- a/doc/codetyper.txt +++ b/doc/codetyper.txt @@ -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 `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`. + 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): + +ctt (Normal) Transform tag at cursor +ctt (Visual) Transform selected tags +ctT (Normal) Transform all tags in file +ca (Normal) Toggle Agent panel +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}) diff --git a/llms.txt b/llms.txt index 5d879e9..d00a8d8 100644 --- a/llms.txt +++ b/llms.txt @@ -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 diff --git a/lua/codetyper/agent/confidence.lua b/lua/codetyper/agent/confidence.lua new file mode 100644 index 0000000..e8e002f --- /dev/null +++ b/lua/codetyper/agent/confidence.lua @@ -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 diff --git a/lua/codetyper/agent/intent.lua b/lua/codetyper/agent/intent.lua new file mode 100644 index 0000000..e3052c8 --- /dev/null +++ b/lua/codetyper/agent/intent.lua @@ -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 diff --git a/lua/codetyper/agent/patch.lua b/lua/codetyper/agent/patch.lua new file mode 100644 index 0000000..8e70058 --- /dev/null +++ b/lua/codetyper/agent/patch.lua @@ -0,0 +1,478 @@ +---@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, + } +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 + +--- Apply a patch to the target buffer +---@param patch PatchCandidate +---@return boolean success +---@return string|nil error +function M.apply(patch) + -- 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 }) + + -- Apply based on strategy + local ok, err = pcall(function() + if patch.injection_strategy == "replace" and patch.injection_range then + -- Replace specific range + vim.api.nvim_buf_set_lines( + target_bufnr, + patch.injection_range.start_line - 1, + patch.injection_range.end_line, + false, + code_lines + ) + elseif patch.injection_strategy == "insert" and patch.injection_range then + -- Insert at specific line + vim.api.nvim_buf_set_lines( + target_bufnr, + patch.injection_range.start_line - 1, + patch.injection_range.start_line - 1, + false, + code_lines + ) + else + -- Default: append to end + local line_count = vim.api.nvim_buf_line_count(target_bufnr) + 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 +function M.flush_pending() + local applied = 0 + local stale = 0 + + for _, patch in ipairs(patches) do + if patch.status == "pending" then + local success, _ = M.apply(patch) + if success then + applied = applied + 1 + else + stale = stale + 1 + end + end + end + + return applied, stale +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 diff --git a/lua/codetyper/agent/queue.lua b/lua/codetyper/agent/queue.lua new file mode 100644 index 0000000..9eb2b61 --- /dev/null +++ b/lua/codetyper/agent/queue.lua @@ -0,0 +1,438 @@ +---@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 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" +---@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 + +--- 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 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 i = 1 + while i <= #queue do + local event = queue[i] + if (event.status == "completed" or event.status == "cancelled") + 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, + } + 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 diff --git a/lua/codetyper/agent/scheduler.lua b/lua/codetyper/agent/scheduler.lua new file mode 100644 index 0000000..fa88e1e --- /dev/null +++ b/lua/codetyper/agent/scheduler.lua @@ -0,0 +1,488 @@ +---@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") + +--- 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, + 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 + +--- Process worker result and decide next action +---@param event table PromptEvent +---@param result table WorkerResult +local function handle_worker_result(event, result) + 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 + M.schedule_patch_flush() +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 + +--- Schedule patch flush after delay (completion safety) +function M.schedule_patch_flush() + vim.defer_fn(function() + local safe, reason = M.is_safe_to_inject() + if safe then + 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, reschedule + pcall(function() + local logs = require("codetyper.agent.logs") + logs.add({ + type = "debug", + message = string.format("Patch flush deferred: %s", reason or "unknown"), + }) + end) + -- Will be retried on next InsertLeave or CursorHold + 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", + }) +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 diff --git a/lua/codetyper/agent/scope.lua b/lua/codetyper/agent/scope.lua new file mode 100644 index 0000000..f530031 --- /dev/null +++ b/lua/codetyper/agent/scope.lua @@ -0,0 +1,444 @@ +---@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) + local ok, parsers = pcall(require, "nvim-treesitter.parsers") + if not ok then + return false + end + + local lang = parsers.get_buf_lang(bufnr) + if not lang then + return false + end + + return parsers.has_parser(lang) +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 +---@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 diff --git a/lua/codetyper/agent/tools.lua b/lua/codetyper/agent/tools.lua index 00bd75f..21d9bbf 100644 --- a/lua/codetyper/agent/tools.lua +++ b/lua/codetyper/agent/tools.lua @@ -97,6 +97,23 @@ function M.to_claude_format() 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() diff --git a/lua/codetyper/agent/worker.lua b/lua/codetyper/agent/worker.lua new file mode 100644 index 0000000..8668d75 --- /dev/null +++ b/lua/codetyper/agent/worker.lua @@ -0,0 +1,419 @@ +---@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 + +--- Active workers +---@type table +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 + +--- 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") + + -- 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, + } + + -- 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" + + -- For replacement intents, provide the full scope to transform + if 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 +``` + +User request: %s + +Return the complete transformed %s. Output only code, no explanations.]], + scope_type, + scope_name, + filetype, + filetype, + event.scope_text, + 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 +``` + +User request: %s + +Output only the code to insert, no explanations.]], + scope_type, + scope_name, + filetype, + event.scope_text, + event.prompt_content + ) + end + else + -- No scope resolved, use full file context + user_prompt = string.format( + [[File: %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 + 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 + + -- Score confidence + local conf_score, breakdown = confidence.score(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 = 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 diff --git a/lua/codetyper/autocmds.lua b/lua/codetyper/autocmds.lua index f659a54..09e2aa3 100644 --- a/lua/codetyper/autocmds.lua +++ b/lua/codetyper/autocmds.lua @@ -140,6 +140,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 @@ -193,11 +206,96 @@ 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") + + -- Take buffer snapshot + local snapshot = patch_mod.snapshot_buffer(bufnr, { + start_line = prompt.start_line, + end_line = prompt.end_line, + }) + + -- Get target path + local current_file = vim.fn.expand("%:p") + local target_path = utils.get_target_path(current_file) + + -- Clean prompt content + local cleaned = parser.clean_prompt(prompt.content) + + -- Detect intent from prompt + local intent = intent_mod.detect(cleaned) + + -- Resolve scope in target file (use prompt position to find enclosing scope) + local target_bufnr = vim.fn.bufnr(target_path) + local scope = nil + local scope_text = nil + local scope_range = nil + + if target_bufnr ~= -1 then + -- Find scope at the corresponding line in target + -- Use the prompt's line position as reference + 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 + 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, + }) + + 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 @@ -268,11 +366,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 +458,178 @@ function M.clear() vim.api.nvim_del_augroup_by_name(AUGROUP) end +--- Track buffers that have been auto-indexed +---@type table +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 = "" 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 diff --git a/lua/codetyper/commands.lua b/lua/codetyper/commands.lua index 0331882..34a11d9 100644 --- a/lua/codetyper/commands.lua +++ b/lua/codetyper/commands.lua @@ -788,6 +788,12 @@ function M.setup() 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" }) + -- Setup default keymaps M.setup_keymaps() end @@ -817,6 +823,12 @@ function M.setup_keymaps() silent = true, desc = "Coder: Toggle Agent panel" }) + + -- Index keymap - open coder companion + vim.keymap.set("n", "ci", "CoderIndex", { + silent = true, + desc = "Coder: Open coder companion for file" + }) end return M diff --git a/lua/codetyper/config.lua b/lua/codetyper/config.lua index 8606694..6cf3f44 100644 --- a/lua/codetyper/config.lua +++ b/lua/codetyper/config.lua @@ -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,14 @@ 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 + }, } --- Deep merge two tables @@ -67,16 +87,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 diff --git a/lua/codetyper/init.lua b/lua/codetyper/init.lua index 6e17cb1..b4c262c 100644 --- a/lua/codetyper/init.lua +++ b/lua/codetyper/init.lua @@ -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 = {} @@ -41,6 +43,12 @@ function M.setup(opts) -- 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) diff --git a/lua/codetyper/llm/claude.lua b/lua/codetyper/llm/claude.lua index 2413a3d..64c76d7 100644 --- a/lua/codetyper/llm/claude.lua +++ b/lua/codetyper/llm/claude.lua @@ -505,4 +505,148 @@ function M.format_messages_for_claude(messages) return formatted 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 api_key = get_api_key() + if not api_key then + 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) + + 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() + callback(nil, "Failed to parse Claude response") + end) + return + end + + if response.error then + vim.schedule(function() + callback(nil, response.error.message or "Claude API error") + end) + return + 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() + 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, + }) +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 diff --git a/lua/codetyper/llm/copilot.lua b/lua/codetyper/llm/copilot.lua index 946001c..5f08231 100644 --- a/lua/codetyper/llm/copilot.lua +++ b/lua/codetyper/llm/copilot.lua @@ -1,531 +1,501 @@ ----Reference implementation: ----https://github.com/zbirenbaum/copilot.lua/blob/master/lua/copilot/auth.lua config file ----https://github.com/zed-industries/zed/blob/ad43bbbf5eda59eba65309735472e0be58b4f7dd/crates/copilot/src/copilot_chat.rs#L272 for authorization ---- ----@class CopilotToken ----@field annotations_enabled boolean ----@field chat_enabled boolean ----@field chat_jetbrains_enabled boolean ----@field code_quote_enabled boolean ----@field codesearch boolean ----@field copilotignore_enabled boolean ----@field endpoints {api: string, ["origin-tracker"]: string, proxy: string, telemetry: string} ----@field expires_at integer ----@field individual boolean ----@field nes_enabled boolean ----@field prompt_8k boolean ----@field public_suggestions string ----@field refresh_in integer ----@field sku string ----@field snippy_load_test_enabled boolean ----@field telemetry string ----@field token string ----@field tracking_id string ----@field vsc_electron_fetcher boolean ----@field xcode boolean ----@field xcode_chat boolean +---@mod codetyper.llm.copilot GitHub Copilot API client for Codetyper.nvim -local curl = require("plenary.curl") - -local Path = require("plenary.path") -local Utils = require("avante.utils") -local Providers = require("avante.providers") -local OpenAI = require("avante.providers").openai - -local H = {} - ----@class AvanteProviderFunctor local M = {} -local copilot_path = vim.fn.stdpath("data") .. "/avante/github-copilot.json" -local lockfile_path = vim.fn.stdpath("data") .. "/avante/copilot-timer.lock" +local utils = require("codetyper.utils") +local llm = require("codetyper.llm") --- Lockfile management -local function is_process_running(pid) - local result = vim.uv.kill(pid, 0) - if result ~= nil and result == 0 then - return true - else - return false - end -end +--- Copilot API endpoints +local AUTH_URL = "https://api.github.com/copilot_internal/v2/token" -local function try_acquire_timer_lock() - local lockfile = Path:new(lockfile_path) +--- Cached state +---@class CopilotState +---@field oauth_token string|nil +---@field github_token table|nil +M.state = nil - local tmp_lockfile = lockfile_path .. ".tmp." .. vim.fn.getpid() - - Path:new(tmp_lockfile):write(tostring(vim.fn.getpid()), "w") - - -- Check existing lock - if lockfile:exists() then - local content = lockfile:read() - local pid = tonumber(content) - if pid and is_process_running(pid) then - os.remove(tmp_lockfile) - return false -- Another instance is already managing - end - end - - -- Attempt to take ownership - local success = os.rename(tmp_lockfile, lockfile_path) - if not success then - os.remove(tmp_lockfile) - return false - end - - return true -end - -local function start_manager_check_timer() - if M._manager_check_timer then - M._manager_check_timer:stop() - M._manager_check_timer:close() - end - - M._manager_check_timer = vim.uv.new_timer() - M._manager_check_timer:start( - 30000, - 30000, - vim.schedule_wrap(function() - if not M._refresh_timer and try_acquire_timer_lock() then - M.setup_timer() - end - end) - ) -end - ----@class OAuthToken ----@field user string ----@field oauth_token string ---- ----@return string -function H.get_oauth_token() +--- 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 = Utils.get_os_name() - ---@type string - local config_dir + 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 vim.tbl_contains({ "linux", "darwin" }, os_name) then + 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 - --- hosts.json (copilot.lua), apps.json (copilot.vim) - ---@type Path[] - local paths = vim.iter({ "hosts.json", "apps.json" }):fold({}, function(acc, path) - local yason = Path:new(config_dir):joinpath("github-copilot", path) - if yason:exists() then - table.insert(acc, yason) - end - return acc - end) - if #paths == 0 then - error("You must setup copilot with either copilot.lua or copilot.vim", 2) - end - - local yason = paths[1] - return vim - .iter( - ---@type table - ---@diagnostic disable-next-line: param-type-mismatch - vim.json.decode(yason:read()) - ) - :filter(function(k, _) - return k:match("github.com") - end) - ---@param acc {oauth_token: string} - :fold({}, function(acc, _, v) - acc.oauth_token = v.oauth_token - return acc - end) - .oauth_token -end - -H.chat_auth_url = "https://api.github.com/copilot_internal/v2/token" -function H.chat_completion_url(base_url) - return Utils.url_join(base_url, "/chat/completions") -end -function H.response_url(base_url) - return Utils.url_join(base_url, "/responses") -end - -function H.refresh_token(async, force) - if not M.state then - error("internal initialization error") - end - - async = async == nil and true or async - force = force or false - - -- Do not refresh token if not forced or not expired - if - not force - and M.state.github_token - and M.state.github_token.expires_at - and M.state.github_token.expires_at > math.floor(os.time()) - then - return false - end - - local provider_conf = Providers.get_config("copilot") - - local curl_opts = { - headers = { - ["Authorization"] = "token " .. M.state.oauth_token, - ["Accept"] = "application/json", - }, - timeout = provider_conf.timeout, - proxy = provider_conf.proxy, - insecure = provider_conf.allow_insecure, - } - - local function handle_response(response) - if response.status == 200 then - M.state.github_token = vim.json.decode(response.body) - local file = Path:new(copilot_path) - file:write(vim.json.encode(M.state.github_token), "w") - if not vim.g.avante_login then - vim.g.avante_login = true - end - - -- If triggered synchronously, reset timer - if not async and M._refresh_timer then - M.setup_timer() - end - - return true - else - error("Failed to get success response: " .. vim.inspect(response)) - return false - end - end - - if async then - curl.get( - H.chat_auth_url, - vim.tbl_deep_extend("force", { - callback = handle_response, - }, curl_opts) - ) - else - local response = curl.get(H.chat_auth_url, curl_opts) - handle_response(response) - end -end - ----@private ----@class AvanteCopilotState ----@field oauth_token string ----@field github_token CopilotToken? -M.state = nil - -M.api_key_name = "" -M.tokenizer_id = "gpt-4o" -M.role_map = { - user = "user", - assistant = "assistant", -} - -function M:is_disable_stream() - return false -end - -setmetatable(M, { __index = OpenAI }) - -function M:list_models() - if M._model_list_cache then - return M._model_list_cache - end - if not M._is_setup then - M.setup() - end - -- refresh token synchronously, only if it has expired - -- (this should rarely happen, as we refresh the token in the background) - H.refresh_token(false, false) - local provider_conf = Providers.parse_config(self) - local headers = self:build_headers() - local curl_opts = { - headers = Utils.tbl_override(headers, self.extra_headers), - timeout = provider_conf.timeout, - proxy = provider_conf.proxy, - insecure = provider_conf.allow_insecure, - } - - local function handle_response(response) - if response.status == 200 then - local body = vim.json.decode(response.body) - -- ref: https://github.com/CopilotC-Nvim/CopilotChat.nvim/blob/16d897fd43d07e3b54478ccdb2f8a16e4df4f45a/lua/CopilotChat/config/providers.lua#L171-L187 - local models = vim.iter(body.data) - :filter(function(model) - return model.capabilities.type == "chat" and not vim.endswith(model.id, "paygo") - end) - :map(function(model) - return { - id = model.id, - display_name = model.name, - name = "copilot/" .. model.name .. " (" .. model.id .. ")", - provider_name = "copilot", - tokenizer = model.capabilities.tokenizer, - max_input_tokens = model.capabilities.limits.max_prompt_tokens, - max_output_tokens = model.capabilities.limits.max_output_tokens, - policy = not model["policy"] or model["policy"]["state"] == "enabled", - version = model.version, - } - end) - :totable() - M._model_list_cache = models - return models - else - error("Failed to get success response: " .. vim.inspect(response)) - return {} - end - end - - local response = curl.get((M.state.github_token.endpoints.api or "") .. "/models", curl_opts) - return handle_response(response) -end - -function M:build_headers() - return { - ["Authorization"] = "Bearer " .. M.state.github_token.token, - ["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 - -function M:parse_curl_args(prompt_opts) - -- refresh token synchronously, only if it has expired - -- (this should rarely happen, as we refresh the token in the background) - H.refresh_token(false, false) - - local provider_conf, request_body = Providers.parse_config(self) - local use_response_api = Providers.resolve_use_response_api(provider_conf, prompt_opts) - local disable_tools = provider_conf.disable_tools or false - - -- Apply OpenAI's set_allowed_params for Response API compatibility - OpenAI.set_allowed_params(provider_conf, request_body) - - local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true - - local tools = nil - if not disable_tools and prompt_opts.tools and not use_ReAct_prompt then - tools = {} - for _, tool in ipairs(prompt_opts.tools) do - local transformed_tool = OpenAI:transform_tool(tool) - -- Response API uses flattened tool structure - if use_response_api then - if transformed_tool.type == "function" and transformed_tool["function"] then - transformed_tool = { - type = "function", - name = transformed_tool["function"].name, - description = transformed_tool["function"].description, - parameters = transformed_tool["function"].parameters, - } + -- 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 - table.insert(tools, transformed_tool) end end - local headers = self:build_headers() - - if prompt_opts.messages and #prompt_opts.messages > 0 then - local last_message = prompt_opts.messages[#prompt_opts.messages] - local initiator = last_message.role == "user" and "user" or "agent" - headers["X-Initiator"] = initiator - end - - local parsed_messages = self:parse_messages(prompt_opts) - - -- Build base body - local base_body = { - model = provider_conf.model, - stream = true, - tools = tools, - } - - -- Response API uses 'input' instead of 'messages' - -- NOTE: Copilot doesn't support previous_response_id, always send full history - if use_response_api then - base_body.input = parsed_messages - - -- Response API uses max_output_tokens instead of max_tokens/max_completion_tokens - if request_body.max_completion_tokens then - request_body.max_output_tokens = request_body.max_completion_tokens - request_body.max_completion_tokens = nil - end - if request_body.max_tokens then - request_body.max_output_tokens = request_body.max_tokens - request_body.max_tokens = nil - end - -- Response API doesn't use stream_options - base_body.stream_options = nil - base_body.include = { "reasoning.encrypted_content" } - base_body.reasoning = { - summary = "detailed", - } - base_body.truncation = "disabled" - else - base_body.messages = parsed_messages - base_body.stream_options = { - include_usage = true, - } - end - - local base_url = M.state.github_token.endpoints.api or provider_conf.endpoint - local build_url = use_response_api and H.response_url or H.chat_completion_url - - return { - url = build_url(base_url), - timeout = provider_conf.timeout, - proxy = provider_conf.proxy, - insecure = provider_conf.allow_insecure, - headers = Utils.tbl_override(headers, self.extra_headers), - body = vim.tbl_deep_extend("force", base_body, request_body), - } + return nil end -M._refresh_timer = nil - -function M.setup_timer() - if M._refresh_timer then - M._refresh_timer:stop() - M._refresh_timer:close() - end - - -- Calculate time until token expires - local now = math.floor(os.time()) - local expires_at = M.state.github_token and M.state.github_token.expires_at or now - local time_until_expiry = math.max(0, expires_at - now) - -- Refresh 2 minutes before expiration - local initial_interval = math.max(0, (time_until_expiry - 120) * 1000) - -- Regular interval of 28 minutes after the first refresh - local repeat_interval = 28 * 60 * 1000 - - M._refresh_timer = vim.uv.new_timer() - M._refresh_timer:start( - initial_interval, - repeat_interval, - vim.schedule_wrap(function() - H.refresh_token(true, true) - 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 -function M.setup_file_watcher() - if M._file_watcher then +--- 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 - local copilot_token_file = Path:new(copilot_path) - M._file_watcher = vim.uv.new_fs_event() + -- 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 - M._file_watcher:start( - copilot_path, - {}, - vim.schedule_wrap(function() - -- Reload token from file - if copilot_token_file:exists() then - local ok, token = pcall(vim.json.decode, copilot_token_file:read()) - if ok then - M.state.github_token = token - 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 - 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 -M._is_setup = false - -function M.is_env_set() - local ok = pcall(function() - H.get_oauth_token() - end) - return ok +--- 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 -function M.setup() - local copilot_token_file = Path:new(copilot_path) +--- 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, - oauth_token = H.get_oauth_token(), } end - - -- Load and validate existing token - if copilot_token_file:exists() then - local ok, token = pcall(vim.json.decode, copilot_token_file:read()) - if ok and token.expires_at and token.expires_at > math.floor(os.time()) then - M.state.github_token = token - end - end - - -- Setup timer management - local timer_lock_acquired = try_acquire_timer_lock() - if timer_lock_acquired then - M.setup_timer() - else - vim.schedule(function() - H.refresh_token(true, false) - end) - end - - M.setup_file_watcher() - - start_manager_check_timer() - - require("avante.tokenizers").setup(M.tokenizer_id) - vim.g.avante_login = true - M._is_setup = true end -function M.cleanup() - -- Cleanup refresh timer - if M._refresh_timer then - M._refresh_timer:stop() - M._refresh_timer:close() - M._refresh_timer = nil +--- 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") - -- Remove lockfile if we were the manager - local lockfile = Path:new(lockfile_path) - if lockfile:exists() then - local content = lockfile:read() - local pid = tonumber(content) - if pid and pid == vim.fn.getpid() then - lockfile:rm() + 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 - end - -- Cleanup manager check timer - if M._manager_check_timer then - M._manager_check_timer:stop() - M._manager_check_timer:close() - M._manager_check_timer = nil - end + local body = { + model = get_model(), + messages = copilot_messages, + max_tokens = 4096, + temperature = 0.3, + stream = false, + tools = tools_module.to_openai_format(), + } - -- Cleanup file watcher - if M._file_watcher then - ---@diagnostic disable-next-line: param-type-mismatch - M._file_watcher:stop() - M._file_watcher = nil - end + 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 --- Register cleanup on Neovim exit -vim.api.nvim_create_autocmd("VimLeavePre", { - callback = function() - M.cleanup() - end, -}) - return M diff --git a/lua/codetyper/llm/gemini.lua b/lua/codetyper/llm/gemini.lua index da8a028..faac70c 100644 --- a/lua/codetyper/llm/gemini.lua +++ b/lua/codetyper/llm/gemini.lua @@ -1,361 +1,394 @@ -local Utils = require("avante.utils") -local Providers = require("avante.providers") -local Clipboard = require("avante.clipboard") -local OpenAI = require("avante.providers").openai -local Prompts = require("avante.utils.prompts") +---@mod codetyper.llm.gemini Google Gemini API client for Codetyper.nvim ----@class AvanteProviderFunctor local M = {} -M.api_key_name = "GEMINI_API_KEY" -M.role_map = { - user = "user", - assistant = "model", -} +local utils = require("codetyper.utils") +local llm = require("codetyper.llm") -function M:is_disable_stream() - return false +--- 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 ----@param tool AvanteLLMTool -function M:transform_to_function_declaration(tool) - local input_schema_properties, required = Utils.llm_tool_param_fields_to_json_schema(tool.param.fields) - local parameters = nil - if not vim.tbl_isempty(input_schema_properties) then - parameters = { - type = "object", - properties = input_schema_properties, - required = required, - } - end - return { - name = tool.name, - description = tool.get_description and tool.get_description() or tool.description, - parameters = parameters, - } +--- 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 -function M:parse_messages(opts) - local provider_conf, _ = Providers.parse_config(self) - local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true - - local contents = {} - local prev_role = nil - - local tool_id_to_name = {} - vim.iter(opts.messages):each(function(message) - local role = message.role - if role == prev_role then - if role == M.role_map["user"] then - table.insert( - contents, - { role = M.role_map["assistant"], parts = { - { text = "Ok, I understand." }, - } } - ) - else - table.insert(contents, { role = M.role_map["user"], parts = { - { text = "Ok" }, - } }) - end - end - prev_role = role - local parts = {} - local content_items = message.content - if type(content_items) == "string" then - table.insert(parts, { text = content_items }) - elseif type(content_items) == "table" then - ---@cast content_items AvanteLLMMessageContentItem[] - for _, item in ipairs(content_items) do - if type(item) == "string" then - table.insert(parts, { text = item }) - elseif type(item) == "table" and item.type == "text" then - table.insert(parts, { text = item.text }) - elseif type(item) == "table" and item.type == "image" then - table.insert(parts, { - inline_data = { - mime_type = "image/png", - data = item.source.data, - }, - }) - elseif type(item) == "table" and item.type == "tool_use" and not use_ReAct_prompt then - tool_id_to_name[item.id] = item.name - role = "model" - table.insert(parts, { - functionCall = { - name = item.name, - args = item.input, - }, - }) - elseif type(item) == "table" and item.type == "tool_result" and not use_ReAct_prompt then - role = "function" - local ok, content = pcall(vim.json.decode, item.content) - if not ok then - content = item.content - end - -- item.name here refers to the name of the tool that was called, - -- which is available in the tool_result content item prepared by llm.lua - local tool_name = item.name - if not tool_name then - -- Fallback, though item.name should ideally always be present for tool_result - tool_name = tool_id_to_name[item.tool_use_id] - end - table.insert(parts, { - functionResponse = { - name = tool_name, - response = { - name = tool_name, -- Gemini API requires the name in the response object as well - content = content, - }, - }, - }) - elseif type(item) == "table" and item.type == "thinking" then - table.insert(parts, { text = item.thinking }) - elseif type(item) == "table" and item.type == "redacted_thinking" then - table.insert(parts, { text = item.data }) - end - end - if not provider_conf.disable_tools and use_ReAct_prompt then - if content_items[1].type == "tool_result" then - local tool_use_msg = nil - for _, msg_ in ipairs(opts.messages) do - if type(msg_.content) == "table" and #msg_.content > 0 then - if - msg_.content[1].type == "tool_use" - and msg_.content[1].id == content_items[1].tool_use_id - then - tool_use_msg = msg_ - break - end - end - end - if tool_use_msg then - table.insert(contents, { - role = "model", - parts = { - { text = Utils.tool_use_to_xml(tool_use_msg.content[1]) }, - }, - }) - role = "user" - table.insert(parts, { - text = "The result of tool use " - .. Utils.tool_use_to_xml(tool_use_msg.content[1]) - .. " is:\n", - }) - table.insert(parts, { - text = content_items[1].content, - }) - end - end - end - end - if #parts > 0 then - table.insert(contents, { role = M.role_map[role] or role, parts = parts }) - end - end) - - if Clipboard.support_paste_image() and opts.image_paths then - for _, image_path in ipairs(opts.image_paths) do - local image_data = { - inline_data = { - mime_type = "image/png", - data = Clipboard.get_base64_content(image_path), - }, - } - - table.insert(contents[#contents].parts, image_data) - end - end - - local system_prompt = opts.system_prompt - - if use_ReAct_prompt then - system_prompt = Prompts.get_ReAct_system_prompt(provider_conf, opts) - 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, - }, + parts = { { text = system_prompt } }, + }, + contents = { + { + role = "user", + parts = { { text = prompt } }, }, }, - contents = contents, + generationConfig = { + temperature = 0.2, + maxOutputTokens = 4096, + }, } end ---- Prepares the main request body for Gemini-like APIs. ----@param provider_instance AvanteProviderFunctor The provider instance (self). ----@param prompt_opts AvantePromptOptions Prompt options including messages, tools, system_prompt. ----@param provider_conf table Provider configuration from config.lua (e.g., model, top-level temperature/max_tokens). ----@param request_body_ table Request-specific overrides, typically from provider_conf.request_config_overrides. ----@return table The fully constructed request body. -function M.prepare_request_body(provider_instance, prompt_opts, provider_conf, request_body_) - local request_body = {} - request_body.generationConfig = request_body_.generationConfig or {} - - local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true - - if use_ReAct_prompt then - request_body.generationConfig.stopSequences = { "" } +--- 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 disable_tools = provider_conf.disable_tools or false + local model = get_model() + local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key + local json_body = vim.json.encode(body) - if not use_ReAct_prompt and not disable_tools and prompt_opts.tools then - local function_declarations = {} - for _, tool in ipairs(prompt_opts.tools) do - table.insert(function_declarations, provider_instance:transform_to_function_declaration(tool)) - end - - if #function_declarations > 0 then - request_body.tools = { - { - functionDeclarations = function_declarations, - }, - } - end - end - - return vim.tbl_deep_extend("force", {}, provider_instance:parse_messages(prompt_opts), request_body) -end - ----@param usage avante.GeminiTokenUsage | nil ----@return avante.LLMTokenUsage | nil -function M.transform_gemini_usage(usage) - if not usage then - return nil - end - ---@type avante.LLMTokenUsage - local res = { - prompt_tokens = usage.promptTokenCount, - completion_tokens = usage.candidatesTokenCount, + local cmd = { + "curl", + "-s", + "-X", + "POST", + url, + "-H", + "Content-Type: application/json", + "-d", + json_body, } - return res -end -function M:parse_response(ctx, data_stream, _, opts) - local ok, jsn = pcall(vim.json.decode, data_stream) - if not ok then - opts.on_stop({ reason = "error", error = "Failed to parse JSON response: " .. tostring(jsn) }) - return - end - - if opts.update_tokens_usage and jsn.usageMetadata and jsn.usageMetadata ~= nil then - local usage = M.transform_gemini_usage(jsn.usageMetadata) - if usage ~= nil then - opts.update_tokens_usage(usage) - end - end - - -- Handle prompt feedback first, as it might indicate an overall issue with the prompt - if jsn.promptFeedback and jsn.promptFeedback.blockReason then - local feedback = jsn.promptFeedback - OpenAI:finish_pending_messages(ctx, opts) -- Ensure any pending messages are cleared - opts.on_stop({ - reason = "error", - error = "Prompt blocked or filtered. Reason: " .. feedback.blockReason, - details = feedback, - }) - return - end - - if jsn.candidates and #jsn.candidates > 0 then - local candidate = jsn.candidates[1] - ---@type AvanteLLMToolUse[] - ctx.tool_use_list = ctx.tool_use_list or {} - - -- Check if candidate.content and candidate.content.parts exist before iterating - if candidate.content and candidate.content.parts then - for _, part in ipairs(candidate.content.parts) do - if part.text then - if opts.on_chunk then - opts.on_chunk(part.text) - end - OpenAI:add_text_message(ctx, part.text, "generating", opts) - elseif part.functionCall then - if not ctx.function_call_id then - ctx.function_call_id = 0 - end - ctx.function_call_id = ctx.function_call_id + 1 - local tool_use = { - id = ctx.turn_id .. "-" .. tostring(ctx.function_call_id), - name = part.functionCall.name, - input_json = vim.json.encode(part.functionCall.args), - } - table.insert(ctx.tool_use_list, tool_use) - OpenAI:add_tool_use_message(ctx, tool_use, "generated", opts) - 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 - end - -- Check for finishReason to determine if this candidate's stream is done. - if candidate.finishReason then - OpenAI:finish_pending_messages(ctx, opts) - local reason_str = candidate.finishReason - local stop_details = { finish_reason = reason_str } - stop_details.usage = M.transform_gemini_usage(jsn.usageMetadata) + local response_text = table.concat(data, "\n") + local ok, response = pcall(vim.json.decode, response_text) - if reason_str == "TOOL_CODE" then - -- Model indicates a tool-related stop. - -- The tool_use list is added to the table in llm.lua - opts.on_stop(vim.tbl_deep_extend("force", { reason = "tool_use" }, stop_details)) - elseif reason_str == "STOP" then - if ctx.tool_use_list and #ctx.tool_use_list > 0 then - -- Natural stop, but tools were found in this final chunk. - opts.on_stop(vim.tbl_deep_extend("force", { reason = "tool_use" }, stop_details)) + 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 - -- Natural stop, no tools in this final chunk. - -- llm.lua will check its accumulated tools if tool_choice was active. - opts.on_stop(vim.tbl_deep_extend("force", { reason = "complete" }, stop_details)) + vim.schedule(function() + callback(nil, "No content in Gemini response", nil) + end) end - elseif reason_str == "MAX_TOKENS" then - opts.on_stop(vim.tbl_deep_extend("force", { reason = "max_tokens" }, stop_details)) - elseif reason_str == "SAFETY" or reason_str == "RECITATION" then - opts.on_stop( - vim.tbl_deep_extend( - "force", - { reason = "error", error = "Generation stopped: " .. reason_str }, - stop_details - ) - ) - else -- OTHER, FINISH_REASON_UNSPECIFIED, or any other unhandled reason. - opts.on_stop( - vim.tbl_deep_extend( - "force", - { reason = "error", error = "Generation stopped with unhandled reason: " .. reason_str }, - stop_details - ) - ) + else + vim.schedule(function() + callback(nil, "No candidates in Gemini response", nil) + end) end - end - -- If no finishReason, it's an intermediate chunk; do not call on_stop. - 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 ----@param prompt_opts AvantePromptOptions ----@return AvanteCurlOutput|nil -function M:parse_curl_args(prompt_opts) - local provider_conf, request_body = Providers.parse_config(self) +--- 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() - local api_key = self:parse_api_key() - if api_key == nil then - Utils.error("Gemini: API key is not set. Please set " .. M.api_key_name) - return nil + -- 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 - return { - url = Utils.url_join( - provider_conf.endpoint, - provider_conf.model .. ":streamGenerateContent?alt=sse&key=" .. api_key - ), - proxy = provider_conf.proxy, - insecure = provider_conf.allow_insecure, - headers = Utils.tbl_override({ ["Content-Type"] = "application/json" }, self.extra_headers), - body = M.prepare_request_body(self, prompt_opts, provider_conf, request_body), + 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 diff --git a/lua/codetyper/llm/init.lua b/lua/codetyper/llm/init.lua index 8df664a..3058d28 100644 --- a/lua/codetyper/llm/init.lua +++ b/lua/codetyper/llm/init.lua @@ -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 diff --git a/lua/codetyper/llm/ollama.lua b/lua/codetyper/llm/ollama.lua index b25bd91..a6f706f 100644 --- a/lua/codetyper/llm/ollama.lua +++ b/lua/codetyper/llm/ollama.lua @@ -394,4 +394,104 @@ function M.generate_with_tools(messages, context, tools, callback) 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 = table.concat(prompt_parts, "\n\n"), + stream = false, + options = { + temperature = 0.2, + num_predict = 4096, + }, + } + + 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") + end) + return + end + + if response.error then + vim.schedule(function() + callback(nil, response.error or "Ollama API error") + end) + return + 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 + vim.schedule(function() + callback(nil, "Ollama API request failed: " .. table.concat(data, "\n")) + end) + end + end, + on_exit = function(_, code) + if code ~= 0 then + vim.schedule(function() + callback(nil, "Ollama API request failed with code: " .. code) + end) + end + end, + }) +end + return M diff --git a/lua/codetyper/llm/openai.lua b/lua/codetyper/llm/openai.lua index b6dbdb8..26b6fcb 100644 --- a/lua/codetyper/llm/openai.lua +++ b/lua/codetyper/llm/openai.lua @@ -1,973 +1,345 @@ -local Utils = require("avante.utils") -local Config = require("avante.config") -local Clipboard = require("avante.clipboard") -local Providers = require("avante.providers") -local HistoryMessage = require("avante.history.message") -local ReActParser = require("avante.libs.ReAct_parser2") -local JsonParser = require("avante.libs.jsonparser") -local Prompts = require("avante.utils.prompts") -local LlmTools = require("avante.llm_tools") +---@mod codetyper.llm.openai OpenAI API client for Codetyper.nvim ----@class AvanteProviderFunctor local M = {} -M.api_key_name = "OPENAI_API_KEY" +local utils = require("codetyper.utils") +local llm = require("codetyper.llm") -M.role_map = { - user = "user", - assistant = "assistant", -} +--- OpenAI API endpoint +local API_URL = "https://api.openai.com/v1/chat/completions" -function M:is_disable_stream() - return false +--- 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 ----@param tool AvanteLLMTool ----@return AvanteOpenAITool -function M:transform_tool(tool) - local input_schema_properties, required = Utils.llm_tool_param_fields_to_json_schema(tool.param.fields) - ---@type AvanteOpenAIToolFunctionParameters - local parameters = { - type = "object", - properties = input_schema_properties, - required = required, - additionalProperties = false, - } - ---@type AvanteOpenAITool - local res = { - type = "function", - ["function"] = { - name = tool.name, - description = tool.get_description and tool.get_description() or tool.description, - parameters = parameters, +--- 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, } - return res end -function M.is_openrouter(url) - return url:match("^https://openrouter%.ai/") -end - -function M.is_mistral(url) - return url:match("^https://api%.mistral%.ai/") -end - ----@param opts AvantePromptOptions -function M.get_user_message(opts) - vim.deprecate("get_user_message", "parse_messages", "0.1.0", "avante.nvim") - return table.concat( - vim.iter(opts.messages) - :filter(function(_, value) - return value == nil or value.role ~= "user" - end) - :fold({}, function(acc, value) - acc = vim.list_extend({}, acc) - acc = vim.list_extend(acc, { value.content }) - return acc - end), - "\n" - ) -end - -function M.is_reasoning_model(model) - return model - and (string.match(model, "^o%d+") ~= nil or (string.match(model, "gpt%-5") ~= nil and model ~= "gpt-5-chat")) -end - -function M.set_allowed_params(provider_conf, request_body) - local use_response_api = Providers.resolve_use_response_api(provider_conf, nil) - if M.is_reasoning_model(provider_conf.model) then - -- Reasoning models have specific parameter requirements - request_body.temperature = 1 - -- Response API doesn't support temperature for reasoning models - if use_response_api then - request_body.temperature = nil - end - else - request_body.reasoning_effort = nil - request_body.reasoning = nil - end - -- If max_tokens is set in config, unset max_completion_tokens - if request_body.max_tokens then - request_body.max_completion_tokens = nil +--- 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 - -- Handle Response API specific parameters - if use_response_api then - -- Convert reasoning_effort to reasoning object for Response API - if request_body.reasoning_effort then - request_body.reasoning = { - effort = request_body.reasoning_effort, - } - request_body.reasoning_effort = nil - end + local endpoint = get_endpoint() + local json_body = vim.json.encode(body) - -- Response API doesn't support some parameters - -- Remove unsupported parameters for Response API - local unsupported_params = { - "top_p", - "frequency_penalty", - "presence_penalty", - "logit_bias", - "logprobs", - "top_logprobs", - "n", - } - for _, param in ipairs(unsupported_params) do - request_body[param] = nil - end - end -end + local cmd = { + "curl", + "-s", + "-X", + "POST", + endpoint, + "-H", + "Content-Type: application/json", + "-H", + "Authorization: Bearer " .. api_key, + "-d", + json_body, + } -function M:parse_messages(opts) - local messages = {} - local provider_conf, _ = Providers.parse_config(self) - local use_response_api = Providers.resolve_use_response_api(provider_conf, opts) - - local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true - local system_prompt = opts.system_prompt - - if use_ReAct_prompt then - system_prompt = Prompts.get_ReAct_system_prompt(provider_conf, opts) - end - - if self.is_reasoning_model(provider_conf.model) then - table.insert(messages, { role = "developer", content = system_prompt }) - else - table.insert(messages, { role = "system", content = system_prompt }) - end - - local has_tool_use = false - - vim.iter(opts.messages):each(function(msg) - if type(msg.content) == "string" then - table.insert(messages, { role = self.role_map[msg.role], content = msg.content }) - elseif type(msg.content) == "table" then - -- Check if this is a reasoning message (object with type "reasoning") - if msg.content.type == "reasoning" then - -- Add reasoning message directly (for Response API) - table.insert(messages, { - type = "reasoning", - id = msg.content.id, - encrypted_content = msg.content.encrypted_content, - summary = msg.content.summary, - }) + 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 content = {} - local tool_calls = {} - local tool_results = {} - for _, item in ipairs(msg.content) do - if type(item) == "string" then - table.insert(content, { type = "text", text = item }) - elseif item.type == "text" then - table.insert(content, { type = "text", text = item.text }) - elseif item.type == "image" then - table.insert(content, { - type = "image_url", - image_url = { - url = "data:" - .. item.source.media_type - .. ";" - .. item.source.type - .. "," - .. item.source.data, - }, - }) - elseif item.type == "reasoning" then - -- Add reasoning message directly (for Response API) - table.insert(messages, { - type = "reasoning", - id = item.id, - encrypted_content = item.encrypted_content, - summary = item.summary, - }) - elseif item.type == "tool_use" and not use_ReAct_prompt then - has_tool_use = true - table.insert(tool_calls, { - id = item.id, - type = "function", - ["function"] = { name = item.name, arguments = vim.json.encode(item.input) }, - }) - elseif item.type == "tool_result" and has_tool_use and not use_ReAct_prompt then - table.insert( - tool_results, - { - tool_call_id = item.tool_use_id, - content = item.is_error and "Error: " .. item.content or item.content, - } - ) - 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 not provider_conf.disable_tools and use_ReAct_prompt then - if msg.content[1].type == "tool_result" then - local tool_use_msg = nil - for _, msg_ in ipairs(opts.messages) do - if type(msg_.content) == "table" and #msg_.content > 0 then - if - msg_.content[1].type == "tool_use" - and msg_.content[1].id == msg.content[1].tool_use_id - then - tool_use_msg = msg_ - break - end - end - end - if tool_use_msg then - msg.role = "user" - table.insert(content, { - type = "text", - text = "The result of tool use " - .. Utils.tool_use_to_xml(tool_use_msg.content[1]) - .. " is:\n", - }) - table.insert(content, { - type = "text", - text = msg.content[1].content, - }) - end - end + + if response.error then + vim.schedule(function() + callback(nil, response.error.message or "OpenAI API error", nil) + end) + return end - if #content > 0 then - table.insert(messages, { role = self.role_map[msg.role], content = content }) - end - if not provider_conf.disable_tools and not use_ReAct_prompt then - if #tool_calls > 0 then - -- Only skip tool_calls if using Response API with previous_response_id support - -- Copilot uses Response API format but doesn't support previous_response_id - local should_include_tool_calls = not use_response_api - or not provider_conf.support_previous_response_id - if should_include_tool_calls then - -- For Response API without previous_response_id support (like Copilot), - -- convert tool_calls to function_call items in input - if use_response_api then - for _, tool_call in ipairs(tool_calls) do - table.insert(messages, { - type = "function_call", - call_id = tool_call.id, - name = tool_call["function"].name, - arguments = tool_call["function"].arguments, - }) - end - else - -- Chat Completions API format - local last_message = messages[#messages] - if - last_message - and last_message.role == self.role_map["assistant"] - and last_message.tool_calls - then - last_message.tool_calls = vim.list_extend(last_message.tool_calls, tool_calls) + -- Extract usage info + local usage = response.usage or {} - if not last_message.content then - last_message.content = "" - end - else - table.insert( - messages, - { role = self.role_map["assistant"], tool_calls = tool_calls, content = "" } - ) - end - end - end - -- If support_previous_response_id is true, Response API manages function call history - -- So we can skip adding tool_calls to input messages - end - if #tool_results > 0 then - for _, tool_result in ipairs(tool_results) do - -- Response API uses different format for function outputs - if use_response_api then - table.insert(messages, { - type = "function_call_output", - call_id = tool_result.tool_call_id, - output = tool_result.content or "", - }) - else - table.insert( - messages, - { - role = "tool", - tool_call_id = tool_result.tool_call_id, - content = tool_result.content or "", - } - ) - end - end - end - end - end - end) - - if Config.behaviour.support_paste_from_clipboard and opts.image_paths and #opts.image_paths > 0 then - local message_content = messages[#messages].content - if type(message_content) ~= "table" or message_content[1] == nil then - message_content = { { type = "text", text = message_content } } - end - for _, image_path in ipairs(opts.image_paths) do - table.insert(message_content, { - type = "image_url", - image_url = { - url = "data:image/png;base64," .. Clipboard.get_base64_content(image_path), - }, - }) - end - messages[#messages].content = message_content - end - - local final_messages = {} - local prev_role = nil - local prev_type = nil - - vim.iter(messages):each(function(message) - local role = message.role - if - role == prev_role - and role ~= "tool" - and prev_type ~= "function_call" - and prev_type ~= "function_call_output" - then - if role == self.role_map["assistant"] then - table.insert(final_messages, { role = self.role_map["user"], content = "Ok" }) + 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 - table.insert(final_messages, { role = self.role_map["assistant"], content = "Ok, I understand." }) + 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 - if role == "user" and prev_role == "tool" and M.is_mistral(provider_conf.endpoint) then - table.insert(final_messages, { role = self.role_map["assistant"], content = "Ok, I understand." }) - end - end - prev_role = role - prev_type = message.type - table.insert(final_messages, message) - end) - - return final_messages -end - -function M:finish_pending_messages(ctx, opts) - if ctx.content ~= nil and ctx.content ~= "" then - self:add_text_message(ctx, "", "generated", opts) - end - if ctx.tool_use_map then - for _, tool_use in pairs(ctx.tool_use_map) do - if tool_use.state == "generating" then - self:add_tool_use_message(ctx, tool_use, "generated", opts) - end - end - end -end - -local llm_tool_names = nil - -function M:add_text_message(ctx, text, state, opts) - if llm_tool_names == nil then - llm_tool_names = LlmTools.get_tool_names() - end - if ctx.content == nil then - ctx.content = "" - end - ctx.content = ctx.content .. text - local content = - ctx.content:gsub("", ""):gsub("", ""):gsub("", ""):gsub("", "") - ctx.content = content - local msg = HistoryMessage:new("assistant", ctx.content, { - state = state, - uuid = ctx.content_uuid, - original_content = ctx.content, - }) - ctx.content_uuid = msg.uuid - local msgs = { msg } - local xml_content = ctx.content - local xml_lines = vim.split(xml_content, "\n") - local cleaned_xml_lines = {} - local prev_tool_name = nil - for _, line in ipairs(xml_lines) do - if line:match("") then - local tool_name = line:match("(.*)") - if tool_name then - prev_tool_name = tool_name - end - elseif line:match("") then - if prev_tool_name then - table.insert(cleaned_xml_lines, "<" .. prev_tool_name .. ">") - end - goto continue - elseif line:match("") then - if prev_tool_name then - table.insert(cleaned_xml_lines, "") - end - goto continue - end - table.insert(cleaned_xml_lines, line) - ::continue:: - end - local cleaned_xml_content = table.concat(cleaned_xml_lines, "\n") - local xml = ReActParser.parse(cleaned_xml_content) - if xml and #xml > 0 then - local new_content_list = {} - local xml_md_openned = false - for idx, item in ipairs(xml) do - if item.type == "text" then - local cleaned_lines = {} - local lines = vim.split(item.text, "\n") - for _, line in ipairs(lines) do - if line:match("^```xml") or line:match("^```tool_code") or line:match("^```tool_use") then - xml_md_openned = true - elseif line:match("^```$") then - if xml_md_openned then - xml_md_openned = false - else - table.insert(cleaned_lines, line) - end - else - table.insert(cleaned_lines, line) - end - end - table.insert(new_content_list, table.concat(cleaned_lines, "\n")) - goto continue - end - if not vim.tbl_contains(llm_tool_names, item.tool_name) then - goto continue - end - local input = {} - for k, v in pairs(item.tool_input or {}) do - local ok, jsn = pcall(vim.json.decode, v) - if ok and jsn then - input[k] = jsn - else - input[k] = v - end - end - if next(input) ~= nil then - local msg_uuid = ctx.content_uuid .. "-" .. idx - local tool_use_id = msg_uuid - local tool_message_state = item.partial and "generating" or "generated" - local msg_ = HistoryMessage:new("assistant", { - type = "tool_use", - name = item.tool_name, - id = tool_use_id, - input = input, - }, { - state = tool_message_state, - uuid = msg_uuid, - turn_id = ctx.turn_id, - }) - msgs[#msgs + 1] = msg_ - ctx.tool_use_map = ctx.tool_use_map or {} - local input_json = type(input) == "string" and input or vim.json.encode(input) - local exists = false - for _, tool_use in pairs(ctx.tool_use_map) do - if tool_use.id == tool_use_id then - tool_use.input_json = input_json - exists = true - end - end - if not exists then - local tool_key = tostring(vim.tbl_count(ctx.tool_use_map)) - ctx.tool_use_map[tool_key] = { - uuid = tool_use_id, - id = tool_use_id, - name = item.tool_name, - input_json = input_json, - state = "generating", - } - end - opts.on_stop({ reason = "tool_use", streaming_tool_use = item.partial }) - end - ::continue:: - end - msg.message.content = table.concat(new_content_list, "\n"):gsub("\n+$", "\n") - end - if opts.on_messages_add then - opts.on_messages_add(msgs) - end -end - -function M:add_thinking_message(ctx, text, state, opts) - if ctx.reasonging_content == nil then - ctx.reasonging_content = "" - end - ctx.reasonging_content = ctx.reasonging_content .. text - local msg = HistoryMessage:new("assistant", { - type = "thinking", - thinking = ctx.reasonging_content, - signature = "", - }, { - state = state, - uuid = ctx.reasonging_content_uuid, - turn_id = ctx.turn_id, - }) - ctx.reasonging_content_uuid = msg.uuid - if opts.on_messages_add then - opts.on_messages_add({ msg }) - end -end - -function M:add_tool_use_message(ctx, tool_use, state, opts) - local jsn = JsonParser.parse(tool_use.input_json) - local msg = HistoryMessage:new("assistant", { - type = "tool_use", - name = tool_use.name, - id = tool_use.id, - input = jsn or {}, - }, { - state = state, - uuid = tool_use.uuid, - turn_id = ctx.turn_id, - }) - tool_use.uuid = msg.uuid - tool_use.state = state - if opts.on_messages_add then - opts.on_messages_add({ msg }) - end - if state == "generating" then - opts.on_stop({ reason = "tool_use", streaming_tool_use = true }) - end -end - -function M:add_reasoning_message(ctx, reasoning_item, opts) - local msg = HistoryMessage:new("assistant", { - type = "reasoning", - id = reasoning_item.id, - encrypted_content = reasoning_item.encrypted_content, - summary = reasoning_item.summary, - }, { - state = "generated", - uuid = Utils.uuid(), - turn_id = ctx.turn_id, - }) - if opts.on_messages_add then - opts.on_messages_add({ msg }) - end -end - ----@param usage avante.OpenAITokenUsage | nil ----@return avante.LLMTokenUsage | nil -function M.transform_openai_usage(usage) - if not usage then - return nil - end - if usage == vim.NIL then - return nil - end - ---@type avante.LLMTokenUsage - local res = { - prompt_tokens = usage.prompt_tokens, - completion_tokens = usage.completion_tokens, - } - return res -end - -function M:parse_response(ctx, data_stream, _, opts) - if data_stream:match('"%[DONE%]":') or data_stream == "[DONE]" then - self:finish_pending_messages(ctx, opts) - if ctx.tool_use_map and vim.tbl_count(ctx.tool_use_map) > 0 then - ctx.tool_use_map = {} - opts.on_stop({ reason = "tool_use" }) - else - opts.on_stop({ reason = "complete" }) - end - return - end - - local jsn = vim.json.decode(data_stream) - - -- Check if this is a Response API event (has 'type' field) - if jsn.type and type(jsn.type) == "string" then - -- Response API event-driven format - if jsn.type == "response.output_text.delta" then - -- Text content delta - if jsn.delta and jsn.delta ~= vim.NIL and jsn.delta ~= "" then - if opts.on_chunk then - opts.on_chunk(jsn.delta) - end - self:add_text_message(ctx, jsn.delta, "generating", opts) - end - elseif jsn.type == "response.reasoning_summary_text.delta" then - -- Reasoning summary delta - if jsn.delta and jsn.delta ~= vim.NIL and jsn.delta ~= "" then - if ctx.returned_think_start_tag == nil or not ctx.returned_think_start_tag then - ctx.returned_think_start_tag = true - if opts.on_chunk then - opts.on_chunk("\n") - end - end - ctx.last_think_content = jsn.delta - self:add_thinking_message(ctx, jsn.delta, "generating", opts) - if opts.on_chunk then - opts.on_chunk(jsn.delta) - end - end - elseif jsn.type == "response.function_call_arguments.delta" then - -- Function call arguments delta - if jsn.delta and jsn.delta ~= vim.NIL and jsn.delta ~= "" then - if not ctx.tool_use_map then - ctx.tool_use_map = {} - end - local tool_key = tostring(jsn.output_index or 0) - if not ctx.tool_use_map[tool_key] then - ctx.tool_use_map[tool_key] = { - name = jsn.name or "", - id = jsn.call_id or "", - input_json = jsn.delta, - } - else - ctx.tool_use_map[tool_key].input_json = ctx.tool_use_map[tool_key].input_json .. jsn.delta - end - end - elseif jsn.type == "response.output_item.added" then - -- Output item added (could be function call or reasoning) - if jsn.item and jsn.item.type == "function_call" then - local tool_key = tostring(jsn.output_index or 0) - if not ctx.tool_use_map then - ctx.tool_use_map = {} - end - ctx.tool_use_map[tool_key] = { - name = jsn.item.name or "", - id = jsn.item.call_id or jsn.item.id or "", - input_json = "", - } - self:add_tool_use_message(ctx, ctx.tool_use_map[tool_key], "generating", opts) - elseif jsn.item and jsn.item.type == "reasoning" then - -- Add reasoning item to history - self:add_reasoning_message(ctx, jsn.item, opts) - end - elseif jsn.type == "response.output_item.done" then - -- Output item done (finalize function call) - if jsn.item and jsn.item.type == "function_call" then - local tool_key = tostring(jsn.output_index or 0) - if ctx.tool_use_map and ctx.tool_use_map[tool_key] then - local tool_use = ctx.tool_use_map[tool_key] - if jsn.item.arguments then - tool_use.input_json = jsn.item.arguments - end - self:add_tool_use_message(ctx, tool_use, "generated", opts) - end - end - elseif jsn.type == "response.completed" or jsn.type == "response.done" then - -- Response completed - save response.id for future requests - if jsn.response and jsn.response.id then - ctx.last_response_id = jsn.response.id - -- Store in provider for next request - self.last_response_id = jsn.response.id - end - if - ctx.returned_think_start_tag ~= nil - and (ctx.returned_think_end_tag == nil or not ctx.returned_think_end_tag) - then - ctx.returned_think_end_tag = true - if opts.on_chunk then - if - ctx.last_think_content - and ctx.last_think_content ~= vim.NIL - and ctx.last_think_content:sub(-1) ~= "\n" - then - opts.on_chunk("\n\n") - else - opts.on_chunk("\n") - end - end - self:add_thinking_message(ctx, "", "generated", opts) - end - self:finish_pending_messages(ctx, opts) - local usage = nil - if jsn.response and jsn.response.usage then - usage = self.transform_openai_usage(jsn.response.usage) - end - if ctx.tool_use_map and vim.tbl_count(ctx.tool_use_map) > 0 then - opts.on_stop({ reason = "tool_use", usage = usage }) - else - opts.on_stop({ reason = "complete", usage = usage }) - end - elseif jsn.type == "error" then - -- Error event - local error_msg = jsn.error and vim.inspect(jsn.error) or "Unknown error" - opts.on_stop({ reason = "error", error = error_msg }) - end - return - end - - -- Chat Completions API format (original code) - if jsn.usage and jsn.usage ~= vim.NIL then - if opts.update_tokens_usage then - local usage = self.transform_openai_usage(jsn.usage) + -- Log token usage if usage then - opts.update_tokens_usage(usage) + 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 - if jsn.error and jsn.error ~= vim.NIL then - opts.on_stop({ reason = "error", error = vim.inspect(jsn.error) }) - return - end - ---@cast jsn AvanteOpenAIChatResponse - if not jsn.choices then - return - end - local choice = jsn.choices[1] - if not choice then - return - end - local delta = choice.delta - if not delta then - local provider_conf = Providers.parse_config(self) - if provider_conf.model:match("o1") then - delta = choice.message - end - end - if not delta then - return - end - if delta.reasoning_content and delta.reasoning_content ~= vim.NIL and delta.reasoning_content ~= "" then - if ctx.returned_think_start_tag == nil or not ctx.returned_think_start_tag then - ctx.returned_think_start_tag = true - if opts.on_chunk then - opts.on_chunk("\n") - end - end - ctx.last_think_content = delta.reasoning_content - self:add_thinking_message(ctx, delta.reasoning_content, "generating", opts) - if opts.on_chunk then - opts.on_chunk(delta.reasoning_content) - end - elseif delta.reasoning and delta.reasoning ~= vim.NIL then - if ctx.returned_think_start_tag == nil or not ctx.returned_think_start_tag then - ctx.returned_think_start_tag = true - if opts.on_chunk then - opts.on_chunk("\n") - end - end - ctx.last_think_content = delta.reasoning - self:add_thinking_message(ctx, delta.reasoning, "generating", opts) - if opts.on_chunk then - opts.on_chunk(delta.reasoning) - end - elseif delta.tool_calls and delta.tool_calls ~= vim.NIL then - local choice_index = choice.index or 0 - for idx, tool_call in ipairs(delta.tool_calls) do - --- In Gemini's so-called OpenAI Compatible API, tool_call.index is nil, which is quite absurd! Therefore, a compatibility fix is needed here. - if tool_call.index == nil then - tool_call.index = choice_index + idx - 1 - end - if not ctx.tool_use_map then - ctx.tool_use_map = {} - end - local tool_key = tostring(tool_call.index) - local prev_tool_key = tostring(tool_call.index - 1) - if not ctx.tool_use_map[tool_key] then - local prev_tool_use = ctx.tool_use_map[prev_tool_key] - if tool_call.index > 0 and prev_tool_use then - self:add_tool_use_message(ctx, prev_tool_use, "generated", opts) - end - local tool_use = { - name = tool_call["function"].name, - id = tool_call.id, - input_json = type(tool_call["function"].arguments) == "string" and tool_call["function"].arguments - or "", - } - ctx.tool_use_map[tool_key] = tool_use - self:add_tool_use_message(ctx, tool_use, "generating", opts) - else - local tool_use = ctx.tool_use_map[tool_key] - if tool_call["function"].arguments == vim.NIL then - tool_call["function"].arguments = "" - end - tool_use.input_json = tool_use.input_json .. tool_call["function"].arguments - -- self:add_tool_use_message(ctx, tool_use, "generating", opts) - end - end - elseif delta.content then - if - ctx.returned_think_start_tag ~= nil - and (ctx.returned_think_end_tag == nil or not ctx.returned_think_end_tag) - then - ctx.returned_think_end_tag = true - if opts.on_chunk then - if - ctx.last_think_content - and ctx.last_think_content ~= vim.NIL - and ctx.last_think_content:sub(-1) ~= "\n" - then - opts.on_chunk("\n\n") - else - opts.on_chunk("\n") - end - end - self:add_thinking_message(ctx, "", "generated", opts) - end - if delta.content ~= vim.NIL then - if opts.on_chunk then - opts.on_chunk(delta.content) - end - self:add_text_message(ctx, delta.content, "generating", opts) - end - end - if choice.finish_reason == "stop" or choice.finish_reason == "eos_token" or choice.finish_reason == "length" then - self:finish_pending_messages(ctx, opts) - if ctx.tool_use_map and vim.tbl_count(ctx.tool_use_map) > 0 then - opts.on_stop({ reason = "tool_use", usage = self.transform_openai_usage(jsn.usage) }) - else - opts.on_stop({ reason = "complete", usage = self.transform_openai_usage(jsn.usage) }) - end - end - if choice.finish_reason == "tool_calls" then - self:finish_pending_messages(ctx, opts) - opts.on_stop({ - reason = "tool_use", - usage = self.transform_openai_usage(jsn.usage), - }) - end + end) end -function M:parse_response_without_stream(data, _, opts) - ---@type AvanteOpenAIChatResponse - local json = vim.json.decode(data) - if json.choices and json.choices[1] then - local choice = json.choices[1] - if choice.message and choice.message.content then - if opts.on_chunk then - opts.on_chunk(choice.message.content) +--- 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 - self:add_text_message({}, choice.message.content, "generated", opts) + 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() - opts.on_stop({ reason = "complete" }) + callback(converted, nil) end) - end - end -end - ----@param prompt_opts AvantePromptOptions ----@return AvanteCurlOutput|nil -function M:parse_curl_args(prompt_opts) - local provider_conf, request_body = Providers.parse_config(self) - local disable_tools = provider_conf.disable_tools or false - - local headers = { - ["Content-Type"] = "application/json", - } - - if Providers.env.require_api_key(provider_conf) then - local api_key = self.parse_api_key() - if api_key == nil then - Utils.error( - Config.provider .. ": API key is not set, please set it in your environment variable or config file" - ) - return nil - end - headers["Authorization"] = "Bearer " .. api_key - end - - if M.is_openrouter(provider_conf.endpoint) then - headers["HTTP-Referer"] = "https://github.com/yetone/avante.nvim" - headers["X-Title"] = "Avante.nvim" - request_body.include_reasoning = true - end - - self.set_allowed_params(provider_conf, request_body) - local use_response_api = Providers.resolve_use_response_api(provider_conf, prompt_opts) - - local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true - - local tools = nil - if not disable_tools and prompt_opts.tools and not use_ReAct_prompt then - tools = {} - for _, tool in ipairs(prompt_opts.tools) do - local transformed_tool = self:transform_tool(tool) - -- Response API uses flattened tool structure - if use_response_api then - -- Convert from {type: "function", function: {name, description, parameters}} - -- to {type: "function", name, description, parameters} - if transformed_tool.type == "function" and transformed_tool["function"] then - transformed_tool = { - type = "function", - name = transformed_tool["function"].name, - description = transformed_tool["function"].description, - parameters = transformed_tool["function"].parameters, - } - 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 - table.insert(tools, transformed_tool) - end - end - - Utils.debug("endpoint", provider_conf.endpoint) - Utils.debug("model", provider_conf.model) - - local stop = nil - if use_ReAct_prompt then - stop = { "" } - end - - -- Determine endpoint path based on use_response_api - local endpoint_path = use_response_api and "/responses" or "/chat/completions" - - local parsed_messages = self:parse_messages(prompt_opts) - - -- Build base body - local base_body = { - model = provider_conf.model, - stop = stop, - stream = true, - tools = tools, - } - - -- Response API uses 'input' instead of 'messages' - if use_response_api then - -- Check if we have tool results - if so, use previous_response_id - local has_function_outputs = false - for _, msg in ipairs(parsed_messages) do - if msg.type == "function_call_output" then - has_function_outputs = true - break + 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 - - if has_function_outputs and self.last_response_id then - -- When sending function outputs, use previous_response_id - base_body.previous_response_id = self.last_response_id - -- Only send the function outputs, not the full history - local function_outputs = {} - for _, msg in ipairs(parsed_messages) do - if msg.type == "function_call_output" then - table.insert(function_outputs, msg) - end - end - base_body.input = function_outputs - -- Clear the stored response_id after using it - self.last_response_id = nil - else - -- Normal request without tool results - base_body.input = parsed_messages - end - - -- Response API uses max_output_tokens instead of max_tokens/max_completion_tokens - if request_body.max_completion_tokens then - request_body.max_output_tokens = request_body.max_completion_tokens - request_body.max_completion_tokens = nil - end - if request_body.max_tokens then - request_body.max_output_tokens = request_body.max_tokens - request_body.max_tokens = nil - end - -- Response API doesn't use stream_options - base_body.stream_options = nil - else - base_body.messages = parsed_messages - base_body.stream_options = not M.is_mistral(provider_conf.endpoint) and { - include_usage = true, - } or nil - end - - return { - url = Utils.url_join(provider_conf.endpoint, endpoint_path), - proxy = provider_conf.proxy, - insecure = provider_conf.allow_insecure, - headers = Utils.tbl_override(headers, self.extra_headers), - body = vim.tbl_deep_extend("force", base_body, request_body), - } + end, + }) end return M diff --git a/lua/codetyper/prompts/agent.lua b/lua/codetyper/prompts/agent.lua index 7b75344..409ec9a 100644 --- a/lua/codetyper/prompts/agent.lua +++ b/lua/codetyper/prompts/agent.lua @@ -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 diff --git a/lua/codetyper/prompts/ask.lua b/lua/codetyper/prompts/ask.lua index 01473a7..37fe450 100644 --- a/lua/codetyper/prompts/ask.lua +++ b/lua/codetyper/prompts/ask.lua @@ -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 diff --git a/lua/codetyper/prompts/code.lua b/lua/codetyper/prompts/code.lua index 8ecd3e4..0826a10 100644 --- a/lua/codetyper/prompts/code.lua +++ b/lua/codetyper/prompts/code.lua @@ -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 diff --git a/lua/codetyper/prompts/document.lua b/lua/codetyper/prompts/document.lua index dfe8680..5067f35 100644 --- a/lua/codetyper/prompts/document.lua +++ b/lua/codetyper/prompts/document.lua @@ -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 diff --git a/lua/codetyper/prompts/refactor.lua b/lua/codetyper/prompts/refactor.lua index b64db8b..2bab37f 100644 --- a/lua/codetyper/prompts/refactor.lua +++ b/lua/codetyper/prompts/refactor.lua @@ -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 diff --git a/lua/codetyper/prompts/system.lua b/lua/codetyper/prompts/system.lua index 1084955..3c22c44 100644 --- a/lua/codetyper/prompts/system.lua +++ b/lua/codetyper/prompts/system.lua @@ -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