17 Commits

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:45:55 -05:00
9c99944f26 updating files 2026-01-11 20:12:13 -05:00
29f321995d fix: improve code prompts to output only raw code
Add explicit instruction to all code generation prompts to return
only raw code without explanations, markdown, or code fences.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:48:17 -05:00
9d52b9364f updating files 2026-01-11 17:58:56 -05:00
86504eec55 updating files 2026-01-11 17:38:25 -05:00
d75196b4f9 updating code malformed 2026-01-11 17:14:14 -05:00
5b90a8e5f9 fix: make transform keymaps work in both normal and visual modes
Fixed keymaps to use command strings instead of function references:
- <leader>ctt (visual): Uses :<C-u>CoderTransformVisual<CR>
- <leader>ctt (normal): Uses <cmd>CoderTransformCursor<CR>
- <leader>ctT (normal): Uses <cmd>CoderTransform<CR>

Also fixed CoderTransformVisual to properly receive line range from opts.
2026-01-11 15:42:34 -05:00
bbdc1aa849 feat: add visual mode transform with <leader>ctt keymap
New keymaps for inline /@ @/ tag transformation:
- <leader>ctt (visual): Transform tags within selection
- <leader>ctt (normal): Transform tag at cursor
- <leader>ctT (normal): Transform ALL tags in file

New command:
- :CoderTransformVisual - Transform tags in visual selection

Usage:
1. Select lines containing /@ @/ tags
2. Press <leader>ctt
3. Selected tags are replaced with generated code
2026-01-11 15:41:42 -05:00
d11a99ee61 feat: add :CoderTransform command for inline tag replacement
New feature: Transform /@ @/ tags directly in any file, not just
.coder.* files. The tags are replaced inline with LLM-generated code.

New commands:
- :CoderTransform - Transform ALL /@ @/ tags in current file
- :CoderTransformCursor - Transform only the tag at cursor position
- :Coder transform - Same as :CoderTransform
- :Coder transform-cursor - Same as :CoderTransformCursor

How it works:
1. Write /@ your prompt @/ anywhere in your code
2. Run :CoderTransform
3. The tag is replaced with generated code matching your file's style

Example:
  /@ create a function to validate email @/

  Becomes:
  function validateEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }
2026-01-11 15:38:41 -05:00
8130a78ed5 fix: make prompts language-agnostic, support all file types
The plugin now properly detects and uses the file's language based on
its extension, rather than focusing on TypeScript.

Changes:
- Updated all system prompts to use {{language}} placeholder
- Removed hardcoded TypeScript references
- Expanded language mappings to support 50+ file extensions:
  - JavaScript/TypeScript (js, ts, jsx, tsx, mjs, cjs)
  - Python (py, pyw, pyx)
  - Systems (c, cpp, rs, go, zig, nim)
  - JVM (java, kt, scala, clj)
  - Web (html, css, scss, vue, svelte)
  - Scripting (lua, rb, php, sh, bash)
  - .NET (cs, fs, vb)
  - Data (json, yaml, toml, sql, graphql)
  - And many more...
2026-01-11 15:33:28 -05:00
fe04e624db fix: ensure LLM outputs only code, not plain text
Improved prompts and code extraction to ensure generated code:
- Matches the file type and existing code structure
- Contains NO markdown code blocks (```)
- Contains NO explanations or comments about what was done
- Is ready to be directly inserted into the target file

Changes:
- Enhanced system prompts with stricter "ABSOLUTE RULES"
- Added explicit instructions about no markdown in user prompts
- Improved extract_code() to strip markdown and explanations
- Added processing notification with prompt preview
2026-01-11 15:31:23 -05:00
62 changed files with 13396 additions and 2103 deletions

95
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,95 @@
name: 🐛 Bug Report
description: Create a bug report to help us improve Avante
title: 'bug: '
labels: ['bug']
body:
- type: markdown
id: issue-already-exists
attributes:
value: |
Please search to see if an issue already exists for the bug you encountered.
See [Searching Issues and Pull Requests](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests) for how to use the GitHub search bar and filters.
- type: textarea
id: describe-the-bug
validations:
required: true
attributes:
label: Describe the bug
description: Please provide a clear and concise description about the problem you ran into.
placeholder: This happened when I ...
- type: textarea
id: to-reproduce
validations:
required: false
attributes:
label: To reproduce
description: |
Please provide a code sample or a code snippet to reproduce said problem. If you have code snippets, error messages, or a stack trace please also provide them here.
**IMPORTANT**: make sure to use [code tags](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks#syntax-highlighting) to correctly format your code. Screenshots are helpful but don't use them for code snippets as they don't allow others to copy-and-paste your code.
placeholder: |
Give a minimal config to reproduce the issue.
- type: textarea
id: expected-behavior
validations:
required: false
attributes:
label: Expected behavior
description: 'A clear and concise description of what you would expect to happen.'
- type: textarea
id: how-to-install
validations:
required: true
attributes:
label: Installation method
description: |
Please share your installation method with us.
value: |
Use lazy.nvim:
```lua
{
"yetone/avante.nvim",
event = "VeryLazy",
lazy = false,
version = false, -- set this if you want to always pull the latest change
opts = {
-- add any opts here
},
-- if you want to build from source then do `make BUILD_FROM_SOURCE=true`
build = "make",
-- build = "powershell -ExecutionPolicy Bypass -File Build.ps1 -BuildFromSource false" -- for windows
dependencies = {
"nvim-lua/plenary.nvim",
"MunifTanjim/nui.nvim",
},
}
```
- type: textarea
id: environment-info
attributes:
label: Environment
description: |
Please share your environment with us, including your neovim version using `nvim -v` and `uname -a`.
placeholder: |
neovim version: ...
distribution (if any): ...
platform: ...
validations:
required: true
- type: textarea
attributes:
label: Repro
description: Minimal `init.lua` to reproduce this issue. Save as `repro.lua` and run with `nvim -u repro.lua`
value: |
vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()
require("lazy.minit").repro({
spec = {
-- add any other plugins here
},
})
render: lua
validations:
required: false

2
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
blank_issues_enabled: true
version: 2.1

View File

@@ -0,0 +1,35 @@
name: 🚀 Feature Request
description: Submit a proposal/request for new Avante feature.
title: 'feature: '
labels: ['new-feature', 'enhancement']
body:
- type: textarea
id: feature-request
validations:
required: true
attributes:
label: Feature request
description: |
A clear and concise description of the feature request.
placeholder: |
I would like it if...
- type: textarea
id: motivation
validations:
required: false
attributes:
label: Motivation
description: |
Please outline the motivation for this feature request. Is your feature request related to a problem? e.g., I'm always frustrated when [...].
If this is related to another issue, please link here too.
If you have a current workaround, please also provide it here.
placeholder: |
This feature would solve ...
- type: textarea
id: other
attributes:
label: Other
description: |
Is there any way that you could help, e.g. by submitting a PR?
placeholder: |
I would love to contribute ...

View File

@@ -0,0 +1,24 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '30 1 * * *'
permissions:
contents: write # only for delete-branch option
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
stale-pr-message: 'This PR is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 10 days.'
close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.'
close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.'
days-before-issue-stale: 30
days-before-pr-stale: 14
days-before-issue-close: 5
days-before-pr-close: 10

86
.github/workflows/lua.yaml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Lua CI
on:
push:
branches:
- main
paths:
- "**/*.lua"
- .github/workflows/lua.yaml
pull_request:
branches:
- main
paths:
- "**/*.lua"
- .github/workflows/lua.yaml
jobs:
# reference from: https://github.com/nvim-lua/plenary.nvim/blob/2d9b06177a975543726ce5c73fca176cedbffe9d/.github/workflows/default.yml#L6C3-L43C20
run_tests:
name: unit tests
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
rev: v0.10.0
steps:
- uses: actions/checkout@v6
- id: todays-date
run: echo "date=$(date +%F)" >> "$GITHUB_OUTPUT"
- name: Restore cache for today's nightly.
id: cache-neovim
uses: actions/cache@v4
with:
path: _neovim
key: ${{ runner.os }}-${{ matrix.rev }}-${{ steps.todays-date.outputs.date }}
- name: Download neovim ${{ matrix.rev }}
env:
GH_TOKEN: ${{ github.token }}
NEOVIM_VERSION: ${{ matrix.rev }}
if: steps.cache-neovim.outputs.cache-hit != 'true'
run: |
mkdir -p _neovim
gh release download \
--output - \
--pattern nvim-linux64.tar.gz \
--repo neovim/neovim \
"$NEOVIM_VERSION" | tar xvz --strip-components 1 --directory _neovim
- name: Prepare
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
sudo apt-get install -y silversearcher-ag
echo "${PWD}/_neovim/bin" >> "$GITHUB_PATH"
echo VIM="${PWD}/_neovim/share/nvim/runtime" >> "$GITHUB_ENV"
- name: Run tests
run: |
nvim --version
make luatest
typecheck:
name: Typecheck
runs-on: ubuntu-latest
strategy:
matrix:
nvim_version: [ stable ]
luals_version: [ 3.13.6 ]
steps:
- uses: actions/checkout@v6
- uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: ${{ matrix.nvim_version }}
- name: Typecheck
env:
VIMRUNTIME: /home/runner/nvim-${{ matrix.nvim_version }}/share/nvim/runtime
run: |
make lua-typecheck

38
.github/workflows/pre-commit.yaml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: pre-commit
on:
pull_request:
types: [labeled, opened, reopened, synchronize]
push:
branches: [main, test-me-*]
jobs:
pre-commit:
if: "github.event.action != 'labeled' || github.event.label.name == 'pre-commit ci run'"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: gh pr edit ${{ github.event.number }} --remove-label 'pre-commit ci run'
if: github.event.action == 'labeled' && github.event.label.name == 'pre-commit ci run'
env:
GH_TOKEN: ${{ github.token }}
- uses: actions/setup-python@v3
with:
python-version: '3.11'
- name: Install uv
uses: astral-sh/setup-uv@v5
- run: |
uv venv
source .venv/bin/activate
uv pip install -r py/rag-service/requirements.txt
- uses: leafo/gh-actions-lua@v11
- uses: leafo/gh-actions-luarocks@v5
- run: luarocks install luacheck
- name: Install stylua from crates.io
uses: baptiste0928/cargo-install@v3
with:
crate: stylua
args: --features lua54
- uses: pre-commit/action@v3.0.1
- uses: pre-commit-ci/lite-action@v1.1.0
if: always()

185
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,185 @@
name: Release
on:
push:
tags: [v\d+\.\d+\.\d+]
permissions:
contents: write
packages: write
env:
CARGO_TERM_COLOR: always
jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-24.04
outputs:
release_id: ${{ steps.create-release.outputs.id }}
release_upload_url: ${{ steps.create-release.outputs.upload_url }}
release_body: "${{ steps.tag.outputs.message }}"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4
- name: Get version
id: get_version
uses: battila7/get-version-action@d97fbc34ceb64d1f5d95f4dfd6dce33521ccccf5 # ratchet:battila7/get-version-action@v2
- name: Get tag message
id: tag
run: |
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
echo "message<<EOF" >> $GITHUB_OUTPUT
echo "$(git tag -l --format='%(contents)' ${{ steps.get_version.outputs.version }})" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
id: create-release
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # ratchet:ncipollo/release-action@v1
with:
draft: true
name: "avante-libs ${{ steps.get_version.outputs.version }}"
tag: ${{ steps.get_version.outputs.version }}
body: "${{ steps.tag.outputs.message }}"
releases-matrix:
needs: [create-release]
strategy:
fail-fast: false
matrix:
feature: [lua51, luajit]
config:
- os: ubuntu-24.04-arm
os_name: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
docker_platform: linux/aarch64
container: quay.io/pypa/manylinux2014_aarch64
- os: ubuntu-latest
os_name: linux
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
docker_platform: linux/amd64
container: quay.io/pypa/manylinux2014_x86_64 # for glibc 2.17
- os: macos-13
os_name: darwin
arch: x86_64
rust_target: x86_64-apple-darwin
- os: macos-latest
os_name: darwin
arch: aarch64
rust_target: aarch64-apple-darwin
- os: windows-latest
os_name: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
- os: windows-latest
os_name: windows
arch: aarch64
rust_target: aarch64-pc-windows-msvc
runs-on: ${{ matrix.config.os }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # ratchet:Swatinem/rust-cache@v2
if: ${{ matrix.config.container == null }}
- uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # ratchet:dtolnay/rust-toolchain@master
if: ${{ matrix.config.container == null }}
with:
targets: ${{ matrix.config.rust_target }}
toolchain: "1.85.0"
- name: Build all crates
if: ${{ matrix.config.container == null }}
run: |
cargo build --release --features ${{ matrix.feature }}
- name: Build all crates with glibc 2.17 # for glibc 2.17
if: ${{ matrix.config.container != null }}
run: |
# sudo apt-get install -y qemu qemu-user-static
docker run \
--rm \
-v $(pwd):/workspace \
-w /workspace \
--platform ${{ matrix.config.docker_platform }} \
${{ matrix.config.container }} \
bash -c "yum install -y perl-IPC-Cmd openssl-devel && curl --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && . /root/.cargo/env && cargo build --release --features ${{ matrix.feature }}"
- name: Handle binaries
if: ${{ matrix.config.os_name != 'windows' }}
shell: bash
run: |
mkdir -p results
if [ "${{ matrix.config.os_name }}" == "linux" ]; then
EXT="so"
else
EXT="dylib"
fi
cp target/release/libavante_templates.$EXT results/avante_templates.$EXT
cp target/release/libavante_tokenizers.$EXT results/avante_tokenizers.$EXT
cp target/release/libavante_repo_map.$EXT results/avante_repo_map.$EXT
cp target/release/libavante_html2md.$EXT results/avante_html2md.$EXT
cd results
tar zcvf avante_lib-${{ matrix.config.os_name }}-${{ matrix.config.arch }}-${{ matrix.feature }}.tar.gz *.${EXT}
- name: Handle binaries (Windows)
if: ${{ matrix.config.os_name == 'windows' }}
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path results
Copy-Item -Path "target\release\avante_templates.dll" -Destination "results\avante_templates.dll"
Copy-Item -Path "target\release\avante_tokenizers.dll" -Destination "results\avante_tokenizers.dll"
Copy-Item -Path "target\release\avante_repo_map.dll" -Destination "results\avante_repo_map.dll"
Copy-Item -Path "target\release\avante_html2md.dll" -Destination "results\avante_html2md.dll"
Set-Location -Path results
$dllFiles = Get-ChildItem -Filter "*.dll" | Select-Object -ExpandProperty Name
Compress-Archive -Path $dllFiles -DestinationPath "avante_lib-${{ matrix.config.os_name }}-${{ matrix.config.arch }}-${{ matrix.feature }}.zip"
- name: Upload Release Asset
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # ratchet:shogo82148/actions-upload-release-asset@v1
if: ${{ matrix.config.os_name != 'windows' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET_NAME: avante_lib-${{ matrix.config.os_name }}-${{ matrix.config.arch }}-${{ matrix.feature }}.tar.gz
with:
upload_url: ${{ needs.create-release.outputs.release_upload_url }}
asset_path: ./results/avante_lib-${{ matrix.config.os_name }}-${{ matrix.config.arch }}-${{ matrix.feature }}.tar.gz
- name: Upload Release Asset (Windows)
uses: shogo82148/actions-upload-release-asset@8482bd769644976d847e96fb4b9354228885e7b4 # ratchet:shogo82148/actions-upload-release-asset@v1
if: ${{ matrix.config.os_name == 'windows' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET_NAME: avante_lib-${{ matrix.config.os_name }}-${{ matrix.config.arch }}-${{ matrix.feature }}.zip
with:
upload_url: ${{ needs.create-release.outputs.release_upload_url }}
asset_path: ./results/avante_lib-${{ matrix.config.os_name }}-${{ matrix.config.arch }}-${{ matrix.feature }}.zip
publish-release:
permissions:
contents: write
runs-on: ubuntu-24.04
needs: [create-release, releases-matrix]
steps:
- name: publish release
id: publish-release
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # ratchet:actions/github-script@v6
env:
release_id: ${{ needs.create-release.outputs.release_id }}
with:
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
draft: false,
prerelease: false
})

31
.github/workflows/rust.yaml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Rust CI
on:
push:
branches:
- main
paths:
- "crates/**/*"
- "Cargo.lock"
- "Cargo.toml"
pull_request:
branches:
- main
paths:
- "crates/**/*"
- "Cargo.lock"
- "Cargo.toml"
jobs:
tests:
name: Run Rust tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # ratchet:Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@7b1c307e0dcbda6122208f10795a713336a9b35a # ratchet:dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy, rustfmt
- name: Run rust tests
run: cargo test --features luajit

49
.gitignore vendored Normal file
View File

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

View File

@@ -7,6 +7,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] - 2026-01-13
### Added
- **Event-Driven Architecture** - Complete rewrite of prompt processing system
- Prompts are now treated as events with metadata (buffer state, priority, timestamps)
- New modules: `queue.lua`, `patch.lua`, `confidence.lua`, `worker.lua`, `scheduler.lua`
- Priority-based event queue with observer pattern
- Buffer snapshots for staleness detection
- **Optimistic Execution** - Ollama as fast local scout
- Use Ollama for first attempt (fast local inference)
- Automatically escalate to remote LLM if confidence is low
- Configurable escalation threshold (default: 0.7)
- **Confidence Scoring** - Response quality heuristics
- 5 weighted heuristics: length, uncertainty phrases, syntax completeness, repetition, truncation
- Scores range from 0.0-1.0
- Determines whether to escalate to more capable LLM
- **Staleness Detection** - Safe patch application
- Track `vim.b.changedtick` and content hash at prompt time
- Discard patches if buffer changed during generation
- Prevents stale code injection
- **Completion-Aware Injection** - No fighting with autocomplete
- Defer code injection while completion popup visible
- Works with native popup, nvim-cmp, and coq_nvim
- Configurable delay after popup closes (default: 100ms)
- **Tree-sitter Scope Resolution** - Smart context extraction
- Automatically resolves prompts to enclosing function/method/class
- Falls back to heuristics when Tree-sitter unavailable
- Scope types: function, method, class, block, file
- **Intent Detection** - Understands what you want
- Parses prompts to detect: complete, refactor, fix, add, document, test, optimize, explain
- Intent determines injection strategy (replace vs insert vs append)
- Priority adjustment based on intent type
- **Tag Precedence Rules** - Multiple tags handled cleanly
- First tag in scope wins (FIFO ordering)
- Later tags in same scope skipped with warning
- Different scopes process independently
### Configuration
New `scheduler` configuration block:
```lua
scheduler = {
enabled = true, -- Enable event-driven mode
ollama_scout = true, -- Use Ollama first
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
}
```
---
## [0.3.0] - 2026-01-13
### Added
- **Multiple LLM Providers** - Support for additional providers beyond Claude and Ollama
- OpenAI API with custom endpoint support (Azure, OpenRouter, etc.)
- Google Gemini API
- GitHub Copilot (uses existing copilot.lua/copilot.vim authentication)
- **Agent Mode** - Autonomous coding assistant with tool use
- `read_file` - Read file contents
- `edit_file` - Edit files with find/replace
- `write_file` - Create or overwrite files
- `bash` - Execute shell commands
- Real-time logging of agent actions
- `:CoderAgent`, `:CoderAgentToggle`, `:CoderAgentStop` commands
- **Transform Commands** - Transform /@ @/ tags inline without split view
- `:CoderTransform` - Transform all tags in file
- `:CoderTransformCursor` - Transform tag at cursor
- `:CoderTransformVisual` - Transform selected tags
- Default keymaps: `<leader>ctt` (cursor/visual), `<leader>ctT` (all)
- **Auto-Index Feature** - Automatically create coder companion files
- Creates `.coder.` companion files when opening source files
- Language-aware templates with correct comment syntax
- `:CoderIndex` command to manually open companion
- `<leader>ci` keymap
- Configurable via `auto_index` option (disabled by default)
- **Logs Panel** - Real-time visibility into LLM operations
- Token usage tracking (prompt and completion tokens)
- "Thinking" process visibility
- Request/response logging
- `:CoderLogs` command to toggle panel
- **Mode Switcher** - Switch between Ask and Agent modes
- `:CoderType` command shows mode selection UI
### Changed
- 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
### Added
@@ -83,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

View File

@@ -39,7 +39,7 @@ This project and everyone participating in it is governed by our commitment to c
1. Clone the repository:
```bash
git clone https://github.com/YOUR_USERNAME/codetyper.nvim.git
git clone https://github.com/CarGDev/codetyper.nvim.git
cd codetyper.nvim
```

646
README.md
View File

@@ -7,29 +7,36 @@
## ✨ Features
- **🪟 Split View**: Work with your code and prompts side by side
- **💬 Ask Panel**: Chat interface for questions and explanations (like avante.nvim)
- **🏷️ Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts
- **🤖 Multiple LLM Providers**: Support for Claude API and Ollama (local)
- **📝 Smart Injection**: Automatically detects prompt type (refactor, add, document)
- **🔒 Git Integration**: Automatically adds `.coder.*` files and `.coder/` folder to `.gitignore`
- **🌳 Project Tree Logging**: Automatically maintains a `tree.log` tracking your project structure
- **⚡ Lazy Loading**: Only loads when you need it
- 📐 **Split View**: Work with your code and prompts side by side
- 💬 **Ask Panel**: Chat interface for questions and explanations
- 🤖 **Agent Mode**: Autonomous coding agent with tool use (read, edit, write, bash)
- 🏷️ **Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts
- **Transform Commands**: Transform prompts inline without leaving your file
- 🔌 **Multiple LLM Providers**: Claude, OpenAI, Gemini, Copilot, and Ollama (local)
- 📋 **Event-Driven Scheduler**: Queue-based processing with optimistic execution
- 🎯 **Tree-sitter Scope Resolution**: Smart context extraction for functions/methods
- 🧠 **Intent Detection**: Understands complete, refactor, fix, add, document intents
- 📊 **Confidence Scoring**: Automatic escalation from local to remote LLMs
- 🛡️ **Completion-Aware**: Safe injection that doesn't fight with autocomplete
- 📁 **Auto-Index**: Automatically create coder companion files on file open
- 📜 **Logs Panel**: Real-time visibility into LLM requests and token usage
- 🔒 **Git Integration**: Automatically adds `.coder.*` files to `.gitignore`
- 🌳 **Project Tree Logging**: Maintains a `tree.log` tracking your project structure
---
## 📋 Table of Contents
## 📚 Table of Contents
- [Requirements](#-requirements)
- [Installation](#-installation)
- [Quick Start](#-quick-start)
- [Configuration](#%EF%B8%8F-configuration)
- [Configuration](#-configuration)
- [LLM Providers](#-llm-providers)
- [Commands Reference](#-commands-reference)
- [Usage Guide](#-usage-guide)
- [How It Works](#%EF%B8%8F-how-it-works)
- [Keymaps](#-keymaps-suggested)
- [Agent Mode](#-agent-mode)
- [Keymaps](#-keymaps)
- [Health Check](#-health-check)
- [Contributing](#-contributing)
---
@@ -37,7 +44,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 = {
{ "<leader>co", "<cmd>Coder open<cr>", desc = "Coder: Open" },
{ "<leader>ct", "<cmd>Coder toggle<cr>", desc = "Coder: Toggle" },
{ "<leader>cp", "<cmd>Coder process<cr>", desc = "Coder: Process" },
{ "<leader>ca", "<cmd>CoderAgentToggle<cr>", desc = "Coder: Agent" },
},
config = function()
require("codetyper").setup({
llm = {
provider = "claude", -- or "ollama"
provider = "claude", -- or "openai", "gemini", "copilot", "ollama"
},
})
end,
@@ -93,8 +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 `<leader>ca`
2. Describe what you want to accomplish
3. The agent will use tools to complete the task
4. Review changes before they're applied
```typescript
/@ Add JSDoc documentation to all exported functions
including @param, @returns, and @example tags @/
```
### Agent Keymaps
#### Implementing Patterns
| Key | Description |
|-----|-------------|
| `<CR>` | 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 |
|-----|------|-------------|
| `<leader>ctt` | Normal | Transform tag at cursor |
| `<leader>ctt` | Visual | Transform selected tags |
| `<leader>ctT` | Normal | Transform all tags in file |
| `<leader>ca` | Normal | Toggle Agent panel |
| `<leader>ci` | Normal | Open coder companion (index) |
### Ask Panel Keymaps
| Key | Description |
|-----|-------------|
| `@` | Attach/reference a file |
| `Ctrl+Enter` | Submit question |
| `Ctrl+n` | Start new chat |
| `Ctrl+f` | Add current file as context |
| `q` | Close panel |
| `Y` | Copy last response |
### Suggested Additional Keymaps
```lua
local map = vim.keymap.set
map("n", "<leader>co", "<cmd>Coder open<cr>", { desc = "Coder: Open" })
map("n", "<leader>cc", "<cmd>Coder close<cr>", { desc = "Coder: Close" })
map("n", "<leader>ct", "<cmd>Coder toggle<cr>", { desc = "Coder: Toggle" })
map("n", "<leader>cp", "<cmd>Coder process<cr>", { desc = "Coder: Process" })
map("n", "<leader>cs", "<cmd>Coder status<cr>", { desc = "Coder: Status" })
```
---
## 🏗️ How It Works
## 🏥 Health Check
```
┌─────────────────────────────────────────────────────────────────┐
│ Neovim │
├────────────────────────────┬────────────────────────────────────┤
│ src/api.coder.ts │ src/api.ts │
│ │ │
│ /@ Create a REST client │ // Generated code appears here │
│ class with methods for │ export class RestClient { │
│ GET, POST, PUT, DELETE │ async get<T>(url: string) { │
│ with TypeScript │ // ... │
│ generics @/ │ } │
│ │ } │
└────────────────────────────┴────────────────────────────────────┘
Verify your setup:
```vim
:checkhealth codetyper
```
### File Structure
This checks:
- Neovim version
- curl availability
- LLM configuration
- API key status
- Telescope availability (optional)
---
## 📁 File Structure
```
your-project/
@@ -477,105 +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", "<leader>co", "<cmd>Coder open<cr>", { desc = "Coder: Open view" })
map("n", "<leader>cc", "<cmd>Coder close<cr>", { desc = "Coder: Close view" })
map("n", "<leader>ct", "<cmd>Coder toggle<cr>", { desc = "Coder: Toggle view" })
map("n", "<leader>cp", "<cmd>Coder process<cr>", { desc = "Coder: Process prompt" })
map("n", "<leader>cs", "<cmd>Coder status<cr>", { desc = "Coder: Show status" })
map("n", "<leader>cf", "<cmd>Coder focus<cr>", { desc = "Coder: Switch focus" })
map("n", "<leader>cv", "<cmd>Coder tree-view<cr>", { desc = "Coder: View tree" })
-- Ask panel
map("n", "<leader>ca", "<cmd>Coder ask<cr>", { desc = "Coder: Open Ask" })
map("n", "<leader>cA", "<cmd>Coder ask-toggle<cr>", { desc = "Coder: Toggle Ask" })
map("n", "<leader>cx", "<cmd>Coder ask-clear<cr>", { desc = "Coder: Clear Ask" })
```
Or with [which-key.nvim](https://github.com/folke/which-key.nvim):
```lua
local wk = require("which-key")
wk.register({
["<leader>c"] = {
name = "+coder",
o = { "<cmd>Coder open<cr>", "Open view" },
c = { "<cmd>Coder close<cr>", "Close view" },
t = { "<cmd>Coder toggle<cr>", "Toggle view" },
p = { "<cmd>Coder process<cr>", "Process prompt" },
s = { "<cmd>Coder status<cr>", "Show status" },
f = { "<cmd>Coder focus<cr>", "Switch focus" },
v = { "<cmd>Coder tree-view<cr>", "View tree" },
},
})
```
---
## 🔧 Health Check
Verify your setup is correct:
```vim
:checkhealth codetyper
```
This checks:
- ✅ Neovim version
- ✅ curl availability
- ✅ LLM configuration
- ✅ API key status
- ✅ Telescope availability (optional)
- ✅ Gitignore configuration
---
## 🤝 Contributing
@@ -590,13 +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
---

View File

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

346
llms.txt
View File

@@ -4,7 +4,7 @@
## Overview
Codetyper.nvim is a Neovim plugin written in Lua that acts as an AI-powered coding partner. It integrates with LLM APIs (Claude, Ollama) to help developers write code faster using a unique prompt-based workflow.
Codetyper.nvim is a Neovim plugin written in Lua that acts as an AI-powered coding partner. It integrates with multiple LLM APIs (Claude, OpenAI, Gemini, Copilot, Ollama) to help developers write code faster using a unique prompt-based workflow.
## Core Concept
@@ -27,15 +27,39 @@ lua/codetyper/
├── commands.lua # Vim command definitions (:Coder, :CoderOpen, etc.)
├── window.lua # Split window management (open, close, toggle)
├── parser.lua # Parses /@ @/ tags from buffer content
├── gitignore.lua # Manages .gitignore entries for coder files and .coder/ folder
├── autocmds.lua # Autocommands for tag detection, filetype, tree updates
├── gitignore.lua # Manages .gitignore entries for coder files
├── autocmds.lua # Autocommands for tag detection, filetype, auto-index
├── inject.lua # Code injection strategies
├── health.lua # Health check for :checkhealth
├── tree.lua # Project tree logging (.coder/tree.log)
── llm/
├── init.lua # LLM interface, provider selection
├── claude.lua # Claude API client (Anthropic)
── ollama.lua # Ollama API client (local LLMs)
── logs_panel.lua # Standalone logs panel UI
├── llm/
├── init.lua # LLM interface, provider selection
── claude.lua # Claude API client (Anthropic)
│ ├── openai.lua # OpenAI API client (with custom endpoint support)
│ ├── gemini.lua # Google Gemini API client
│ ├── copilot.lua # GitHub Copilot client (uses OAuth from copilot.lua/vim)
│ └── ollama.lua # Ollama API client (local LLMs)
├── agent/
│ ├── init.lua # Agent system entry point
│ ├── ui.lua # Agent panel UI
│ ├── logs.lua # Logging system with listeners
│ ├── tools.lua # Tool definitions (read_file, edit_file, write_file, bash)
│ ├── executor.lua # Tool execution logic
│ ├── parser.lua # Parse tool calls from LLM responses
│ ├── queue.lua # Event queue with priority heap
│ ├── patch.lua # Patch candidates with staleness detection
│ ├── confidence.lua # Response confidence scoring heuristics
│ ├── worker.lua # Async LLM worker wrapper
│ ├── scheduler.lua # Event scheduler with completion-awareness
│ ├── scope.lua # Tree-sitter scope resolution
│ └── intent.lua # Intent detection from prompts
├── ask/
│ ├── init.lua # Ask panel entry point
│ └── ui.lua # Ask panel UI (chat interface)
└── prompts/
├── init.lua # System prompts for code generation
└── agent.lua # Agent-specific prompts and tool instructions
```
## .coder/ Folder
@@ -47,51 +71,240 @@ The plugin automatically creates and maintains a `.coder/` folder in your projec
└── tree.log # Project structure, auto-updated on file changes
```
The `tree.log` contains:
- Project name and timestamp
- Full directory tree with file type icons
- Automatically ignores: hidden files, node_modules, .git, build folders, coder files
## Key Features
Tree updates are triggered by:
- `BufWritePost` - When files are saved
- `BufNewFile` - When new files are created
- `BufDelete` - When files are deleted
- `DirChanged` - When changing directories
### 1. Multiple LLM Providers
Updates are debounced (1 second) to prevent excessive writes.
## Key Functions
### Setup
```lua
require("codetyper").setup({
llm = { provider = "claude" | "ollama", ... },
window = { width = 0.4, position = "left" },
patterns = { open_tag = "/@", close_tag = "@/" },
auto_gitignore = true,
})
llm = {
provider = "claude", -- "claude", "openai", "gemini", "copilot", "ollama"
claude = { api_key = nil, model = "claude-sonnet-4-20250514" },
openai = { api_key = nil, model = "gpt-4o", endpoint = nil },
gemini = { api_key = nil, model = "gemini-2.0-flash" },
copilot = { model = "gpt-4o" },
ollama = { host = "http://localhost:11434", model = "deepseek-coder:6.7b" },
}
```
### Commands
### 2. Agent Mode
Autonomous coding assistant with tool access:
- `read_file` - Read file contents
- `edit_file` - Edit files with find/replace
- `write_file` - Create or overwrite files
- `bash` - Execute shell commands
### 3. Transform Commands
Transform `/@ @/` tags inline without split view:
- `:CoderTransform` - Transform all tags in file
- `:CoderTransformCursor` - Transform tag at cursor
- `:CoderTransformVisual` - Transform selected tags
### 4. Auto-Index
Automatically create coder companion files when opening source files:
```lua
auto_index = true -- disabled by default
```
### 5. Logs Panel
Real-time visibility into LLM operations with token usage tracking.
### 6. Event-Driven Scheduler
Prompts are treated as events, not commands:
```
User types /@...@/ → Event queued → Scheduler dispatches → Worker processes → Patch created → Safe injection
```
**Key concepts:**
- **PromptEvent**: Captures buffer state (changedtick, content hash) at prompt time
- **Optimistic Execution**: Ollama as fast scout, escalate to remote LLMs if confidence low
- **Confidence Scoring**: 5 heuristics (length, uncertainty, syntax, repetition, truncation)
- **Staleness Detection**: Discard patches if buffer changed during generation
- **Completion Safety**: Defer injection while autocomplete popup visible
**Configuration:**
```lua
scheduler = {
enabled = true, -- Enable event-driven mode
ollama_scout = true, -- Use Ollama first
escalation_threshold = 0.7, -- Below this → escalate
max_concurrent = 2, -- Parallel workers
completion_delay_ms = 100, -- Wait after popup closes
}
```
### 7. Tree-sitter Scope Resolution
Prompts automatically resolve to their enclosing function/method/class:
```lua
function foo()
/@ complete this function @/ -- Resolves to `foo`
end
```
**Scope types:** `function`, `method`, `class`, `block`, `file`
For replacement intents (complete, refactor, fix), the entire scope is extracted
and sent to the LLM, then replaced with the transformed version.
### 8. Intent Detection
The system parses prompts to detect user intent:
| Intent | Keywords | Action |
|--------|----------|--------|
| complete | complete, finish, implement | replace |
| refactor | refactor, rewrite, simplify | replace |
| fix | fix, repair, debug, bug | replace |
| add | add, create, insert, new | insert |
| document | document, comment, jsdoc | replace |
| test | test, spec, unit test | append |
| optimize | optimize, performance, faster | replace |
| explain | explain, what, how, why | none |
### 9. Tag Precedence
Multiple tags in the same scope follow "first tag wins" rule:
- Earlier (by line number) unresolved tag processes first
- Later tags in same scope are skipped with warning
- Different scopes process independently
## Commands
### Main Commands
- `:Coder open` - Opens split view with coder file
- `:Coder close` - Closes the split
- `:Coder toggle` - Toggles the view
- `:Coder process` - Manually triggers code generation
- `:Coder status` - Shows configuration status and project stats
- `:Coder tree` - Manually refresh tree.log
- `:Coder tree-view` - Open tree.log in split view
### Prompt Tags
- Opening tag: `/@`
- Closing tag: `@/`
- Content between tags is the prompt sent to LLM
### Ask Panel
- `:CoderAsk` - Open Ask panel
- `:CoderAskToggle` - Toggle Ask panel
- `:CoderAskClear` - Clear chat history
### Prompt Types (Auto-detected)
- `refactor` - Modifies existing code
- `add` - Adds new code at cursor/end
- `document` - Adds documentation/comments
- `explain` - Explanations (no code injection)
- `generic` - User chooses injection method
### Agent Mode
- `:CoderAgent` - Open Agent panel
- `:CoderAgentToggle` - Toggle Agent panel
- `:CoderAgentStop` - Stop running agent
### Transform
- `:CoderTransform` - Transform all tags
- `:CoderTransformCursor` - Transform at cursor
- `:CoderTransformVisual` - Transform selection
### Utility
- `:CoderIndex` - Open coder companion
- `:CoderLogs` - Toggle logs panel
- `:CoderType` - Switch Ask/Agent mode
- `:CoderTree` - Refresh tree.log
- `:CoderTreeView` - View tree.log
## Configuration Schema
```lua
{
llm = {
provider = "claude", -- "claude" | "openai" | "gemini" | "copilot" | "ollama"
claude = {
api_key = nil, -- string, uses ANTHROPIC_API_KEY env if nil
model = "claude-sonnet-4-20250514",
},
openai = {
api_key = nil, -- string, uses OPENAI_API_KEY env if nil
model = "gpt-4o",
endpoint = nil, -- custom endpoint for Azure, OpenRouter, etc.
},
gemini = {
api_key = nil, -- string, uses GEMINI_API_KEY env if nil
model = "gemini-2.0-flash",
},
copilot = {
model = "gpt-4o", -- uses OAuth from copilot.lua/copilot.vim
},
ollama = {
host = "http://localhost:11434",
model = "deepseek-coder:6.7b",
},
},
window = {
width = 25, -- percentage (25 = 25% of screen)
position = "left", -- "left" | "right"
border = "rounded",
},
patterns = {
open_tag = "/@",
close_tag = "@/",
file_pattern = "*.coder.*",
},
auto_gitignore = true,
auto_open_ask = true,
auto_index = false, -- auto-create coder companion files
scheduler = {
enabled = true, -- enable event-driven scheduler
ollama_scout = true, -- use Ollama as fast scout
escalation_threshold = 0.7,
max_concurrent = 2,
completion_delay_ms = 100,
},
}
```
## LLM Integration
### Claude API
- Endpoint: `https://api.anthropic.com/v1/messages`
- Uses `x-api-key` header for authentication
- Supports tool use for agent mode
### OpenAI API
- Endpoint: `https://api.openai.com/v1/chat/completions` (configurable)
- Uses `Authorization: Bearer` header
- Supports tool use for agent mode
- Compatible with Azure, OpenRouter, and other OpenAI-compatible APIs
### Gemini API
- Endpoint: `https://generativelanguage.googleapis.com/v1beta/models`
- Uses API key in URL parameter
- Supports function calling for agent mode
### Copilot API
- Uses GitHub OAuth token from copilot.lua/copilot.vim
- Endpoint from token response (typically `api.githubcopilot.com`)
- OpenAI-compatible format
### Ollama API
- Endpoint: `{host}/api/generate` or `{host}/api/chat`
- No authentication required for local instances
- Tool use via prompt-based approach
## Agent Tool Definitions
```lua
tools = {
read_file = { path: string },
edit_file = { path: string, find: string, replace: string },
write_file = { path: string, content: string },
bash = { command: string, timeout?: number },
}
```
## Code Injection Strategies
1. **Refactor**: Replace entire file content
2. **Add**: Insert at cursor position in target file
3. **Document**: Insert above current function/class
4. **Generic**: Prompt user for action
## File Naming Convention
@@ -103,62 +316,13 @@ require("codetyper").setup({
Pattern: `name.coder.extension`
## Configuration Schema
```lua
{
llm = {
provider = "claude", -- "claude" | "ollama"
claude = {
api_key = nil, -- string, uses ANTHROPIC_API_KEY env if nil
model = "claude-sonnet-4-20250514",
},
ollama = {
host = "http://localhost:11434",
model = "codellama",
},
},
window = {
width = 0.4, -- number (percentage if <=1, columns if >1)
position = "left", -- "left" | "right"
border = "rounded", -- border style for floating windows
},
patterns = {
open_tag = "/@", -- string
close_tag = "@/", -- string
file_pattern = "*.coder.*",
},
auto_gitignore = true, -- boolean
}
```
## LLM Integration
### Claude API
- Endpoint: `https://api.anthropic.com/v1/messages`
- Uses `x-api-key` header for authentication
- Requires `anthropic-version: 2023-06-01` header
### Ollama API
- Endpoint: `{host}/api/generate`
- No authentication required for local instances
- Health check via `/api/tags` endpoint
## Code Injection Strategies
1. **Refactor**: Replace entire file content
2. **Add**: Insert at cursor position in target file
3. **Document**: Insert above current function/class
4. **Generic**: Prompt user for action (replace/insert/append/clipboard)
## Dependencies
- **Required**: Neovim >= 0.8.0, curl
- **Optional**: telescope.nvim (enhanced file picker)
- **Optional**: telescope.nvim (enhanced file picker), copilot.lua or copilot.vim (for Copilot provider)
## Contact
- Author: cargdev
- Email: carlos.gutierrez@carg.dev
- Website: https://cargdev.io
- Blog: https://blog.cargdev.io

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
---@mod codetyper.agent.tools Tool definitions for the agent system
---
--- Defines available tools that the LLM can use to interact with files and system.
local M = {}
--- Tool definitions in a provider-agnostic format
M.definitions = {
read_file = {
name = "read_file",
description = "Read the contents of a file at the specified path",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Absolute or relative path to the file to read",
},
},
required = { "path" },
},
},
edit_file = {
name = "edit_file",
description = "Edit a file by replacing specific content. Provide the exact content to find and the replacement.",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Path to the file to edit",
},
find = {
type = "string",
description = "Exact content to find (must match exactly, including whitespace)",
},
replace = {
type = "string",
description = "Content to replace with",
},
},
required = { "path", "find", "replace" },
},
},
write_file = {
name = "write_file",
description = "Write content to a file, creating it if it doesn't exist or overwriting if it does",
parameters = {
type = "object",
properties = {
path = {
type = "string",
description = "Path to the file to write",
},
content = {
type = "string",
description = "Complete file content to write",
},
},
required = { "path", "content" },
},
},
bash = {
name = "bash",
description = "Execute a bash command and return the output. Use for git, npm, build tools, etc.",
parameters = {
type = "object",
properties = {
command = {
type = "string",
description = "The bash command to execute",
},
timeout = {
type = "number",
description = "Timeout in milliseconds (default: 30000)",
},
},
required = { "command" },
},
},
}
--- Convert tool definitions to Claude API format
---@return table[] Tools in Claude's expected format
function M.to_claude_format()
local tools = {}
for _, tool in pairs(M.definitions) do
table.insert(tools, {
name = tool.name,
description = tool.description,
input_schema = tool.parameters,
})
end
return tools
end
--- Convert tool definitions to OpenAI API format
---@return table[] Tools in OpenAI's expected format
function M.to_openai_format()
local tools = {}
for _, tool in pairs(M.definitions) do
table.insert(tools, {
type = "function",
["function"] = {
name = tool.name,
description = tool.description,
parameters = tool.parameters,
},
})
end
return tools
end
--- Convert tool definitions to prompt format for Ollama
---@return string Formatted tool descriptions for system prompt
function M.to_prompt_format()
local lines = {
"You have access to the following tools. To use a tool, respond with a JSON block.",
"",
}
for _, tool in pairs(M.definitions) do
table.insert(lines, "## " .. tool.name)
table.insert(lines, tool.description)
table.insert(lines, "")
table.insert(lines, "Parameters:")
for prop_name, prop in pairs(tool.parameters.properties) do
local required = vim.tbl_contains(tool.parameters.required or {}, prop_name)
local req_str = required and " (required)" or " (optional)"
table.insert(lines, " - " .. prop_name .. ": " .. prop.description .. req_str)
end
table.insert(lines, "")
end
table.insert(lines, "---")
table.insert(lines, "")
table.insert(lines, "To call a tool, output a JSON block like this:")
table.insert(lines, "```json")
table.insert(lines, '{"tool": "tool_name", "parameters": {"param1": "value1"}}')
table.insert(lines, "```")
table.insert(lines, "")
table.insert(lines, "After receiving tool results, continue your response or call another tool.")
table.insert(lines, "When you're done, just respond normally without any tool calls.")
return table.concat(lines, "\n")
end
--- Get a list of tool names
---@return string[]
function M.get_tool_names()
local names = {}
for name, _ in pairs(M.definitions) do
table.insert(names, name)
end
return names
end
return M

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

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

View File

@@ -0,0 +1,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<string, Worker>
local active_workers = {}
--- Default timeouts by provider type
local default_timeouts = {
ollama = 30000, -- 30s for local
claude = 60000, -- 60s for remote
openai = 60000,
gemini = 60000,
copilot = 60000,
}
--- Generate worker ID
---@return string
local function generate_id()
worker_counter = worker_counter + 1
return string.format("worker_%d_%d", os.time(), worker_counter)
end
--- Get LLM client by type
---@param worker_type string
---@return table|nil client
---@return string|nil error
local function get_client(worker_type)
local ok, client = pcall(require, "codetyper.llm." .. worker_type)
if ok and client then
return client, nil
end
return nil, "Unknown provider: " .. worker_type
end
--- 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

File diff suppressed because it is too large Load Diff

View File

@@ -20,182 +20,296 @@ local processed_prompts = {}
---@param prompt table Prompt object
---@return string Unique key
local function get_prompt_key(bufnr, prompt)
return string.format("%d:%d:%d:%s", bufnr, prompt.start_line, prompt.end_line, prompt.content:sub(1, 50))
return string.format("%d:%d:%d:%s", bufnr, prompt.start_line, prompt.end_line, prompt.content:sub(1, 50))
end
--- Schedule tree update with debounce
local function schedule_tree_update()
if tree_update_timer then
tree_update_timer:stop()
end
if tree_update_timer then
tree_update_timer:stop()
end
tree_update_timer = vim.defer_fn(function()
local tree = require("codetyper.tree")
tree.update_tree_log()
tree_update_timer = nil
end, TREE_UPDATE_DEBOUNCE_MS)
tree_update_timer = vim.defer_fn(function()
local tree = require("codetyper.tree")
tree.update_tree_log()
tree_update_timer = nil
end, TREE_UPDATE_DEBOUNCE_MS)
end
--- Setup autocommands
function M.setup()
local group = vim.api.nvim_create_augroup(AUGROUP, { clear = true })
local group = vim.api.nvim_create_augroup(AUGROUP, { clear = true })
-- Auto-save coder file when leaving insert mode
vim.api.nvim_create_autocmd("InsertLeave", {
group = group,
pattern = "*.coder.*",
callback = function()
-- Auto-save the coder file
if vim.bo.modified then
vim.cmd("silent! write")
end
-- Check for closed prompts and auto-process
M.check_for_closed_prompt()
end,
desc = "Auto-save and check for closed prompt tags",
})
-- Auto-save coder file when leaving insert mode
vim.api.nvim_create_autocmd("InsertLeave", {
group = group,
pattern = "*.coder.*",
callback = function()
-- Auto-save the coder file
if vim.bo.modified then
vim.cmd("silent! write")
end
-- Check for closed prompts and auto-process
M.check_for_closed_prompt()
end,
desc = "Auto-save and check for closed prompt tags",
})
-- Auto-set filetype for coder files based on extension
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
group = group,
pattern = "*.coder.*",
callback = function()
M.set_coder_filetype()
end,
desc = "Set filetype for coder files",
})
-- Auto-set filetype for coder files based on extension
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
group = group,
pattern = "*.coder.*",
callback = function()
M.set_coder_filetype()
end,
desc = "Set filetype for coder files",
})
-- Auto-open split view when opening a coder file directly (e.g., from nvim-tree)
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
pattern = "*.coder.*",
callback = function()
-- Delay slightly to ensure buffer is fully loaded
vim.defer_fn(function()
M.auto_open_target_file()
end, 50)
end,
desc = "Auto-open target file when coder file is opened",
})
-- Auto-open split view when opening a coder file directly (e.g., from nvim-tree)
vim.api.nvim_create_autocmd("BufEnter", {
group = group,
pattern = "*.coder.*",
callback = function()
-- Delay slightly to ensure buffer is fully loaded
vim.defer_fn(function()
M.auto_open_target_file()
end, 50)
end,
desc = "Auto-open target file when coder file is opened",
})
-- Cleanup on buffer close
vim.api.nvim_create_autocmd("BufWipeout", {
group = group,
pattern = "*.coder.*",
callback = function(ev)
local window = require("codetyper.window")
if window.is_open() then
window.close_split()
end
-- Clear processed prompts for this buffer
local bufnr = ev.buf
for key, _ in pairs(processed_prompts) do
if key:match("^" .. bufnr .. ":") then
processed_prompts[key] = nil
end
end
-- Clear auto-opened tracking
M.clear_auto_opened(bufnr)
end,
desc = "Cleanup on coder buffer close",
})
-- Cleanup on buffer close
vim.api.nvim_create_autocmd("BufWipeout", {
group = group,
pattern = "*.coder.*",
callback = function(ev)
local window = require("codetyper.window")
if window.is_open() then
window.close_split()
end
-- Clear processed prompts for this buffer
local bufnr = ev.buf
for key, _ in pairs(processed_prompts) do
if key:match("^" .. bufnr .. ":") then
processed_prompts[key] = nil
end
end
-- Clear auto-opened tracking
M.clear_auto_opened(bufnr)
end,
desc = "Cleanup on coder buffer close",
})
-- Update tree.log when files are created/written
vim.api.nvim_create_autocmd({ "BufWritePost", "BufNewFile" }, {
group = group,
pattern = "*",
callback = function(ev)
-- Skip coder files and tree.log itself
local filepath = ev.file or vim.fn.expand("%:p")
if filepath:match("%.coder%.") or filepath:match("tree%.log$") then
return
end
-- Schedule tree update with debounce
schedule_tree_update()
end,
desc = "Update tree.log on file creation/save",
})
-- Update tree.log when files are created/written
vim.api.nvim_create_autocmd({ "BufWritePost", "BufNewFile" }, {
group = group,
pattern = "*",
callback = function(ev)
-- Skip coder files and tree.log itself
local filepath = ev.file or vim.fn.expand("%:p")
if filepath:match("%.coder%.") or filepath:match("tree%.log$") then
return
end
-- Schedule tree update with debounce
schedule_tree_update()
end,
desc = "Update tree.log on file creation/save",
})
-- Update tree.log when files are deleted (via netrw or file explorer)
vim.api.nvim_create_autocmd("BufDelete", {
group = group,
pattern = "*",
callback = function(ev)
local filepath = ev.file or ""
-- Skip special buffers and coder files
if filepath == "" or filepath:match("%.coder%.") or filepath:match("tree%.log$") then
return
end
schedule_tree_update()
end,
desc = "Update tree.log on file deletion",
})
-- Update tree.log when files are deleted (via netrw or file explorer)
vim.api.nvim_create_autocmd("BufDelete", {
group = group,
pattern = "*",
callback = function(ev)
local filepath = ev.file or ""
-- Skip special buffers and coder files
if filepath == "" or filepath:match("%.coder%.") or filepath:match("tree%.log$") then
return
end
schedule_tree_update()
end,
desc = "Update tree.log on file deletion",
})
-- Update tree on directory change
vim.api.nvim_create_autocmd("DirChanged", {
group = group,
pattern = "*",
callback = function()
schedule_tree_update()
end,
desc = "Update tree.log on directory change",
})
-- Update tree on directory change
vim.api.nvim_create_autocmd("DirChanged", {
group = group,
pattern = "*",
callback = function()
schedule_tree_update()
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
local function get_config_safe()
local codetyper = require("codetyper")
local config = codetyper.get_config()
-- Return defaults if not initialized
if not config or not config.patterns then
return {
patterns = {
open_tag = "/@",
close_tag = "@/",
file_pattern = "*.coder.*",
}
}
end
return config
end
--- Check if the buffer has a newly closed prompt and auto-process
function M.check_for_closed_prompt()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local parser = require("codetyper.parser")
local config = get_config_safe()
local parser = require("codetyper.parser")
local bufnr = vim.api.nvim_get_current_buf()
local bufnr = vim.api.nvim_get_current_buf()
-- Get current line
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local lines = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)
-- Get current line
local cursor = vim.api.nvim_win_get_cursor(0)
local line = cursor[1]
local lines = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)
if #lines == 0 then
return
end
if #lines == 0 then
return
end
local current_line = lines[1]
local current_line = lines[1]
-- Check if line contains closing tag
if parser.has_closing_tag(current_line, config.patterns.close_tag) then
-- Find the complete prompt
local prompt = parser.get_last_prompt(bufnr)
if prompt and prompt.content and prompt.content ~= "" then
-- Generate unique key for this prompt
local prompt_key = get_prompt_key(bufnr, prompt)
-- Check if line contains closing tag
if parser.has_closing_tag(current_line, config.patterns.close_tag) then
-- Find the complete prompt
local prompt = parser.get_last_prompt(bufnr)
if prompt and prompt.content and prompt.content ~= "" then
-- Generate unique key for this prompt
local prompt_key = get_prompt_key(bufnr, prompt)
-- Check if already processed
if processed_prompts[prompt_key] then
return
end
-- Check if already processed
if processed_prompts[prompt_key] then
return
end
-- Mark as processed
processed_prompts[prompt_key] = true
-- 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)
end
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
--- Reset processed prompts for a buffer (useful for re-processing)
---@param bufnr? number Buffer number (default: current)
function M.reset_processed(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
for key, _ in pairs(processed_prompts) do
if key:match("^" .. bufnr .. ":") then
processed_prompts[key] = nil
end
end
utils.notify("Prompt history cleared - prompts can be re-processed")
bufnr = bufnr or vim.api.nvim_get_current_buf()
for key, _ in pairs(processed_prompts) do
if key:match("^" .. bufnr .. ":") then
processed_prompts[key] = nil
end
end
utils.notify("Prompt history cleared - prompts can be re-processed")
end
--- Track if we already opened the split for this buffer
@@ -204,146 +318,318 @@ local auto_opened_buffers = {}
--- Auto-open target file when a coder file is opened directly
function M.auto_open_target_file()
local window = require("codetyper.window")
local window = require("codetyper.window")
-- Skip if split is already open
if window.is_open() then
return
end
-- Skip if split is already open
if window.is_open() then
return
end
local bufnr = vim.api.nvim_get_current_buf()
local bufnr = vim.api.nvim_get_current_buf()
-- Skip if we already handled this buffer
if auto_opened_buffers[bufnr] then
return
end
-- Skip if we already handled this buffer
if auto_opened_buffers[bufnr] then
return
end
local current_file = vim.fn.expand("%:p")
local current_file = vim.fn.expand("%:p")
-- Skip empty paths
if not current_file or current_file == "" then
return
end
-- Skip empty paths
if not current_file or current_file == "" then
return
end
-- Verify it's a coder file
if not utils.is_coder_file(current_file) then
return
end
-- Verify it's a coder file
if not utils.is_coder_file(current_file) then
return
end
-- Skip if we're in a special buffer (nvim-tree, etc.)
local buftype = vim.bo[bufnr].buftype
if buftype ~= "" then
return
end
-- Skip if we're in a special buffer (nvim-tree, etc.)
local buftype = vim.bo[bufnr].buftype
if buftype ~= "" then
return
end
-- Mark as handled
auto_opened_buffers[bufnr] = true
-- Mark as handled
auto_opened_buffers[bufnr] = true
-- Get the target file path
local target_path = utils.get_target_path(current_file)
-- Get the target file path
local target_path = utils.get_target_path(current_file)
-- Check if target file exists
if not utils.file_exists(target_path) then
utils.notify("Target file not found: " .. vim.fn.fnamemodify(target_path, ":t"), vim.log.levels.WARN)
return
end
-- Check if target file exists
if not utils.file_exists(target_path) then
utils.notify("Target file not found: " .. vim.fn.fnamemodify(target_path, ":t"), vim.log.levels.WARN)
return
end
-- Get config with fallback defaults
local codetyper = require("codetyper")
local config = codetyper.get_config()
-- Get config with fallback defaults
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()
local coder_buf = bufnr
-- Store current coder window
local coder_win = vim.api.nvim_get_current_win()
local coder_buf = bufnr
-- Open target file in a vertical split on the right
local ok, err = pcall(function()
vim.cmd("vsplit " .. vim.fn.fnameescape(target_path))
end)
-- Open target file in a vertical split on the right
local ok, err = pcall(function()
vim.cmd("vsplit " .. vim.fn.fnameescape(target_path))
end)
if not ok then
utils.notify("Failed to open target file: " .. tostring(err), vim.log.levels.ERROR)
auto_opened_buffers[bufnr] = nil -- Allow retry
return
end
if not ok then
utils.notify("Failed to open target file: " .. tostring(err), vim.log.levels.ERROR)
auto_opened_buffers[bufnr] = nil -- Allow retry
return
end
-- Now we're in the target window (right side)
local target_win = vim.api.nvim_get_current_win()
local target_buf = vim.api.nvim_get_current_buf()
-- Now we're in the target window (right side)
local target_win = vim.api.nvim_get_current_win()
local target_buf = vim.api.nvim_get_current_buf()
-- Set the coder window width (left side)
pcall(vim.api.nvim_win_set_width, coder_win, width)
-- Set the coder window width (left side)
pcall(vim.api.nvim_win_set_width, coder_win, width)
-- Update window module state
window._coder_win = coder_win
window._coder_buf = coder_buf
window._target_win = target_win
window._target_buf = target_buf
-- Update window module state
window._coder_win = coder_win
window._coder_buf = coder_buf
window._target_win = target_win
window._target_buf = target_buf
-- Set up window options for coder window
pcall(function()
vim.wo[coder_win].number = true
vim.wo[coder_win].relativenumber = true
vim.wo[coder_win].signcolumn = "yes"
end)
-- Set up window options for coder window
pcall(function()
vim.wo[coder_win].number = true
vim.wo[coder_win].relativenumber = true
vim.wo[coder_win].signcolumn = "yes"
end)
utils.notify("Opened target: " .. vim.fn.fnamemodify(target_path, ":t"))
utils.notify("Opened target: " .. vim.fn.fnamemodify(target_path, ":t"))
end
--- Clear auto-opened tracking for a buffer
---@param bufnr number Buffer number
function M.clear_auto_opened(bufnr)
auto_opened_buffers[bufnr] = nil
auto_opened_buffers[bufnr] = nil
end
--- Set appropriate filetype for coder files
function M.set_coder_filetype()
local filepath = vim.fn.expand("%:p")
local filepath = vim.fn.expand("%:p")
-- Extract the actual extension (e.g., index.coder.ts -> ts)
local ext = filepath:match("%.coder%.(%w+)$")
-- Extract the actual extension (e.g., index.coder.ts -> ts)
local ext = filepath:match("%.coder%.(%w+)$")
if ext then
-- Map extension to filetype
local ft_map = {
ts = "typescript",
tsx = "typescriptreact",
js = "javascript",
jsx = "javascriptreact",
py = "python",
lua = "lua",
go = "go",
rs = "rust",
rb = "ruby",
java = "java",
c = "c",
cpp = "cpp",
cs = "cs",
json = "json",
yaml = "yaml",
yml = "yaml",
md = "markdown",
html = "html",
css = "css",
scss = "scss",
vue = "vue",
svelte = "svelte",
}
if ext then
-- Map extension to filetype
local ft_map = {
ts = "typescript",
tsx = "typescriptreact",
js = "javascript",
jsx = "javascriptreact",
py = "python",
lua = "lua",
go = "go",
rs = "rust",
rb = "ruby",
java = "java",
c = "c",
cpp = "cpp",
cs = "cs",
json = "json",
yaml = "yaml",
yml = "yaml",
md = "markdown",
html = "html",
css = "css",
scss = "scss",
vue = "vue",
svelte = "svelte",
}
local filetype = ft_map[ext] or ext
vim.bo.filetype = filetype
end
local filetype = ft_map[ext] or ext
vim.bo.filetype = filetype
end
end
--- Clear all autocommands
function M.clear()
vim.api.nvim_del_augroup_by_name(AUGROUP)
vim.api.nvim_del_augroup_by_name(AUGROUP)
end
--- Track buffers that have been auto-indexed
---@type table<number, boolean>
local auto_indexed_buffers = {}
--- Supported file extensions for auto-indexing
local supported_extensions = {
"ts", "tsx", "js", "jsx", "py", "lua", "go", "rs", "rb",
"java", "c", "cpp", "cs", "json", "yaml", "yml", "md",
"html", "css", "scss", "vue", "svelte", "php", "sh", "zsh",
}
--- Check if extension is supported
---@param ext string File extension
---@return boolean
local function is_supported_extension(ext)
for _, supported in ipairs(supported_extensions) do
if ext == supported then
return true
end
end
return false
end
--- Auto-index a file by creating/opening its coder companion
---@param bufnr number Buffer number
function M.auto_index_file(bufnr)
-- Skip if buffer is invalid
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-- Skip if already indexed
if auto_indexed_buffers[bufnr] then
return
end
-- Get file path
local filepath = vim.api.nvim_buf_get_name(bufnr)
if not filepath or filepath == "" then
return
end
-- Skip coder files
if utils.is_coder_file(filepath) then
return
end
-- Skip special buffers
local buftype = vim.bo[bufnr].buftype
if buftype ~= "" then
return
end
-- Skip unsupported file types
local ext = vim.fn.fnamemodify(filepath, ":e")
if ext == "" or not is_supported_extension(ext) then
return
end
-- Skip if auto_index is disabled in config
local codetyper = require("codetyper")
local config = codetyper.get_config()
if config and config.auto_index == false then
return
end
-- Mark as indexed
auto_indexed_buffers[bufnr] = true
-- Get coder companion path
local coder_path = utils.get_coder_path(filepath)
-- Check if coder file already exists
local coder_exists = utils.file_exists(coder_path)
-- Create coder file with template if it doesn't exist
if not coder_exists then
local filename = vim.fn.fnamemodify(filepath, ":t")
local template = string.format(
[[-- Coder companion for %s
-- Use /@ @/ tags to write pseudo-code prompts
-- Example:
-- /@
-- Add a function that validates user input
-- - Check for empty strings
-- - Validate email format
-- @/
]],
filename
)
utils.write_file(coder_path, template)
end
-- Notify user about the coder companion
local coder_filename = vim.fn.fnamemodify(coder_path, ":t")
if coder_exists then
utils.notify("Coder companion available: " .. coder_filename, vim.log.levels.DEBUG)
else
utils.notify("Created coder companion: " .. coder_filename, vim.log.levels.INFO)
end
end
--- Open the coder companion for the current file
---@param open_split? boolean Whether to open in split view (default: true)
function M.open_coder_companion(open_split)
open_split = open_split ~= false -- Default to true
local filepath = vim.fn.expand("%:p")
if not filepath or filepath == "" then
utils.notify("No file open", vim.log.levels.WARN)
return
end
if utils.is_coder_file(filepath) then
utils.notify("Already in coder file", vim.log.levels.INFO)
return
end
local coder_path = utils.get_coder_path(filepath)
-- Create if it doesn't exist
if not utils.file_exists(coder_path) then
local filename = vim.fn.fnamemodify(filepath, ":t")
local ext = vim.fn.fnamemodify(filepath, ":e")
local comment_prefix = "--"
if vim.tbl_contains({ "js", "jsx", "ts", "tsx", "java", "c", "cpp", "cs", "go", "rs", "php" }, ext) then
comment_prefix = "//"
elseif vim.tbl_contains({ "py", "sh", "zsh", "yaml", "yml" }, ext) then
comment_prefix = "#"
elseif vim.tbl_contains({ "html", "md" }, ext) then
comment_prefix = "<!--"
end
local close_comment = comment_prefix == "<!--" and " -->" or ""
local template = string.format(
[[%s Coder companion for %s%s
%s Use /@ @/ tags to write pseudo-code prompts%s
%s Example:%s
%s /@%s
%s Add a function that validates user input%s
%s - Check for empty strings%s
%s - Validate email format%s
%s @/%s
]],
comment_prefix, filename, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment,
comment_prefix, close_comment
)
utils.write_file(coder_path, template)
end
if open_split then
-- Use the window module to open split view
local window = require("codetyper.window")
window.open_split(coder_path, filepath)
else
-- Just open the coder file
vim.cmd("edit " .. vim.fn.fnameescape(coder_path))
end
end
--- Clear auto-indexed tracking for a buffer
---@param bufnr number Buffer number
function M.clear_auto_indexed(bufnr)
auto_indexed_buffers[bufnr] = nil
end
return M

View File

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

View File

@@ -88,6 +88,23 @@ local function cmd_toggle()
window.toggle_split(target_path, coder_path)
end
--- Build enhanced user prompt with context
---@param clean_prompt string The cleaned user prompt
---@param context table Context information
---@return string Enhanced prompt
local function build_user_prompt(clean_prompt, context)
local enhanced = "TASK: " .. clean_prompt .. "\n\n"
enhanced = enhanced .. "REQUIREMENTS:\n"
enhanced = enhanced .. "- Generate ONLY " .. (context.language or "code") .. " code\n"
enhanced = enhanced .. "- NO markdown code blocks (no ```)\n"
enhanced = enhanced .. "- NO explanations or comments about what you did\n"
enhanced = enhanced .. "- Match the coding style of the existing file exactly\n"
enhanced = enhanced .. "- Output must be ready to insert directly into the file\n"
return enhanced
end
--- Process prompt at cursor and generate code
local function cmd_process()
local parser = require("codetyper.parser")
@@ -111,8 +128,13 @@ local function cmd_process()
local prompt_type = parser.detect_prompt_type(prompt.content)
local context = llm.build_context(target_path, prompt_type)
local clean_prompt = parser.clean_prompt(prompt.content)
-- Build enhanced prompt with explicit instructions
local enhanced_prompt = build_user_prompt(clean_prompt, context)
llm.generate(clean_prompt, context, function(response, err)
utils.notify("Processing: " .. clean_prompt:sub(1, 50) .. "...", vim.log.levels.INFO)
llm.generate(enhanced_prompt, context, function(response, err)
if err then
utils.notify("Generation failed: " .. err, vim.log.levels.ERROR)
return
@@ -122,6 +144,7 @@ local function cmd_process()
-- Inject code into target file
local inject = require("codetyper.inject")
inject.inject_code(target_path, response, prompt_type)
utils.notify("Code generated and injected!", vim.log.levels.INFO)
end
end)
end
@@ -229,6 +252,47 @@ local function cmd_ask_clear()
ask.clear_history()
end
--- Open agent panel
local function cmd_agent()
local agent_ui = require("codetyper.agent.ui")
agent_ui.open()
end
--- Close agent panel
local function cmd_agent_close()
local agent_ui = require("codetyper.agent.ui")
agent_ui.close()
end
--- Toggle agent panel
local function cmd_agent_toggle()
local agent_ui = require("codetyper.agent.ui")
agent_ui.toggle()
end
--- Stop running agent
local function cmd_agent_stop()
local agent = require("codetyper.agent")
if agent.is_running() then
agent.stop()
utils.notify("Agent stopped")
else
utils.notify("No agent running", vim.log.levels.INFO)
end
end
--- Show chat type switcher modal (Ask/Agent)
local function cmd_type_toggle()
local switcher = require("codetyper.chat_switcher")
switcher.show()
end
--- Toggle logs panel
local function cmd_logs_toggle()
local logs_panel = require("codetyper.logs_panel")
logs_panel.toggle()
end
--- Switch focus between coder and target windows
local function cmd_focus()
if not window.is_open() then
@@ -244,6 +308,355 @@ local function cmd_focus()
end
end
--- Transform inline /@ @/ tags in current file
--- Works on ANY file, not just .coder.* files
local function cmd_transform()
local parser = require("codetyper.parser")
local llm = require("codetyper.llm")
local logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
if filepath == "" then
utils.notify("No file in current buffer", vim.log.levels.WARN)
return
end
-- Find all prompts in the current buffer
local prompts = parser.find_prompts_in_buffer(bufnr)
if #prompts == 0 then
utils.notify("No /@ @/ tags found in current file", vim.log.levels.INFO)
return
end
-- Open the logs panel to show generation progress
logs_panel.open()
logs.info("Transform started: " .. #prompts .. " prompt(s)")
utils.notify("Found " .. #prompts .. " prompt(s) to transform...", vim.log.levels.INFO)
-- Build context for this file
local ext = vim.fn.fnamemodify(filepath, ":e")
local context = llm.build_context(filepath, "code_generation")
-- Process prompts in reverse order (bottom to top) to maintain line numbers
local sorted_prompts = {}
for i = #prompts, 1, -1 do
table.insert(sorted_prompts, prompts[i])
end
-- Track how many are being processed
local pending = #sorted_prompts
local completed = 0
local errors = 0
-- Process each prompt
for _, prompt in ipairs(sorted_prompts) do
local clean_prompt = parser.clean_prompt(prompt.content)
local prompt_type = parser.detect_prompt_type(prompt.content)
-- Build enhanced user prompt
local enhanced_prompt = "TASK: " .. clean_prompt .. "\n\n"
enhanced_prompt = enhanced_prompt .. "REQUIREMENTS:\n"
enhanced_prompt = enhanced_prompt .. "- Generate ONLY " .. (context.language or "code") .. " code\n"
enhanced_prompt = enhanced_prompt .. "- NO markdown code blocks (no ```)\n"
enhanced_prompt = enhanced_prompt .. "- NO explanations or comments about what you did\n"
enhanced_prompt = enhanced_prompt .. "- Match the coding style of the existing file exactly\n"
enhanced_prompt = enhanced_prompt .. "- Output must be ready to insert directly into the file\n"
logs.info("Processing: " .. clean_prompt:sub(1, 40) .. "...")
utils.notify("Processing: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO)
-- Generate code for this prompt
llm.generate(enhanced_prompt, context, function(response, err)
if err then
logs.error("Failed: " .. err)
utils.notify("Failed: " .. err, vim.log.levels.ERROR)
errors = errors + 1
elseif response then
-- Replace the prompt tag with generated code
vim.schedule(function()
-- Get current buffer lines
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
-- Calculate the exact range to replace
local start_line = prompt.start_line
local end_line = prompt.end_line
-- Find the full lines containing the tags
local start_line_content = lines[start_line] or ""
local end_line_content = lines[end_line] or ""
-- Check if there's content before the opening tag on the same line
local codetyper = require("codetyper")
local config = codetyper.get_config()
local before_tag = ""
local after_tag = ""
local open_pos = start_line_content:find(utils.escape_pattern(config.patterns.open_tag))
if open_pos and open_pos > 1 then
before_tag = start_line_content:sub(1, open_pos - 1)
end
local close_pos = end_line_content:find(utils.escape_pattern(config.patterns.close_tag))
if close_pos then
local after_close = close_pos + #config.patterns.close_tag
if after_close <= #end_line_content then
after_tag = end_line_content:sub(after_close)
end
end
-- Build the replacement lines
local replacement_lines = vim.split(response, "\n", { plain = true })
-- Add before/after content if any
if before_tag ~= "" and #replacement_lines > 0 then
replacement_lines[1] = before_tag .. replacement_lines[1]
end
if after_tag ~= "" and #replacement_lines > 0 then
replacement_lines[#replacement_lines] = replacement_lines[#replacement_lines] .. after_tag
end
-- Replace the lines in buffer
vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, replacement_lines)
completed = completed + 1
if completed + errors >= pending then
local msg = "Transform complete: " .. completed .. " succeeded, " .. errors .. " failed"
logs.info(msg)
utils.notify(msg, errors > 0 and vim.log.levels.WARN or vim.log.levels.INFO)
end
end)
end
end)
end
end
--- Transform prompts within a line range (for visual selection)
---@param start_line number Start line (1-indexed)
---@param end_line number End line (1-indexed)
local function cmd_transform_range(start_line, end_line)
local parser = require("codetyper.parser")
local llm = require("codetyper.llm")
local logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
if filepath == "" then
utils.notify("No file in current buffer", vim.log.levels.WARN)
return
end
-- Find all prompts in the current buffer
local all_prompts = parser.find_prompts_in_buffer(bufnr)
-- Filter prompts that are within the selected range
local prompts = {}
for _, prompt in ipairs(all_prompts) do
if prompt.start_line >= start_line and prompt.end_line <= end_line then
table.insert(prompts, prompt)
end
end
if #prompts == 0 then
utils.notify("No /@ @/ tags found in selection (lines " .. start_line .. "-" .. end_line .. ")", vim.log.levels.INFO)
return
end
-- Open the logs panel to show generation progress
logs_panel.open()
logs.info("Transform selection: " .. #prompts .. " prompt(s)")
utils.notify("Found " .. #prompts .. " prompt(s) in selection to transform...", vim.log.levels.INFO)
-- Build context for this file
local context = llm.build_context(filepath, "code_generation")
-- Process prompts in reverse order (bottom to top) to maintain line numbers
local sorted_prompts = {}
for i = #prompts, 1, -1 do
table.insert(sorted_prompts, prompts[i])
end
local pending = #sorted_prompts
local completed = 0
local errors = 0
for _, prompt in ipairs(sorted_prompts) do
local clean_prompt = parser.clean_prompt(prompt.content)
local enhanced_prompt = "TASK: " .. clean_prompt .. "\n\n"
enhanced_prompt = enhanced_prompt .. "REQUIREMENTS:\n"
enhanced_prompt = enhanced_prompt .. "- Generate ONLY " .. (context.language or "code") .. " code\n"
enhanced_prompt = enhanced_prompt .. "- NO markdown code blocks (no ```)\n"
enhanced_prompt = enhanced_prompt .. "- NO explanations or comments about what you did\n"
enhanced_prompt = enhanced_prompt .. "- Match the coding style of the existing file exactly\n"
enhanced_prompt = enhanced_prompt .. "- Output must be ready to insert directly into the file\n"
logs.info("Processing: " .. clean_prompt:sub(1, 40) .. "...")
utils.notify("Processing: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO)
llm.generate(enhanced_prompt, context, function(response, err)
if err then
logs.error("Failed: " .. err)
utils.notify("Failed: " .. err, vim.log.levels.ERROR)
errors = errors + 1
elseif response then
vim.schedule(function()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local p_start_line = prompt.start_line
local p_end_line = prompt.end_line
local start_line_content = lines[p_start_line] or ""
local end_line_content = lines[p_end_line] or ""
local codetyper = require("codetyper")
local config = codetyper.get_config()
local before_tag = ""
local after_tag = ""
local open_pos = start_line_content:find(utils.escape_pattern(config.patterns.open_tag))
if open_pos and open_pos > 1 then
before_tag = start_line_content:sub(1, open_pos - 1)
end
local close_pos = end_line_content:find(utils.escape_pattern(config.patterns.close_tag))
if close_pos then
local after_close = close_pos + #config.patterns.close_tag
if after_close <= #end_line_content then
after_tag = end_line_content:sub(after_close)
end
end
local replacement_lines = vim.split(response, "\n", { plain = true })
if before_tag ~= "" and #replacement_lines > 0 then
replacement_lines[1] = before_tag .. replacement_lines[1]
end
if after_tag ~= "" and #replacement_lines > 0 then
replacement_lines[#replacement_lines] = replacement_lines[#replacement_lines] .. after_tag
end
vim.api.nvim_buf_set_lines(bufnr, p_start_line - 1, p_end_line, false, replacement_lines)
completed = completed + 1
if completed + errors >= pending then
local msg = "Transform complete: " .. completed .. " succeeded, " .. errors .. " failed"
logs.info(msg)
utils.notify(msg, errors > 0 and vim.log.levels.WARN or vim.log.levels.INFO)
end
end)
end
end)
end
end
--- Command wrapper for visual selection transform
local function cmd_transform_visual()
-- Get visual selection marks
local start_line = vim.fn.line("'<")
local end_line = vim.fn.line("'>")
cmd_transform_range(start_line, end_line)
end
--- Transform a single prompt at cursor position
local function cmd_transform_at_cursor()
local parser = require("codetyper.parser")
local llm = require("codetyper.llm")
local logs_panel = require("codetyper.logs_panel")
local logs = require("codetyper.agent.logs")
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.fn.expand("%:p")
if filepath == "" then
utils.notify("No file in current buffer", vim.log.levels.WARN)
return
end
-- Find prompt at cursor
local prompt = parser.get_prompt_at_cursor(bufnr)
if not prompt then
utils.notify("No /@ @/ tag at cursor position", vim.log.levels.WARN)
return
end
-- Open the logs panel to show generation progress
logs_panel.open()
local clean_prompt = parser.clean_prompt(prompt.content)
local context = llm.build_context(filepath, "code_generation")
logs.info("Transform cursor: " .. clean_prompt:sub(1, 40) .. "...")
-- Build enhanced user prompt
local enhanced_prompt = "TASK: " .. clean_prompt .. "\n\n"
enhanced_prompt = enhanced_prompt .. "REQUIREMENTS:\n"
enhanced_prompt = enhanced_prompt .. "- Generate ONLY " .. (context.language or "code") .. " code\n"
enhanced_prompt = enhanced_prompt .. "- NO markdown code blocks (no ```)\n"
enhanced_prompt = enhanced_prompt .. "- NO explanations or comments about what you did\n"
enhanced_prompt = enhanced_prompt .. "- Match the coding style of the existing file exactly\n"
enhanced_prompt = enhanced_prompt .. "- Output must be ready to insert directly into the file\n"
utils.notify("Transforming: " .. clean_prompt:sub(1, 40) .. "...", vim.log.levels.INFO)
llm.generate(enhanced_prompt, context, function(response, err)
if err then
logs.error("Transform failed: " .. err)
utils.notify("Transform failed: " .. err, vim.log.levels.ERROR)
return
end
if response then
vim.schedule(function()
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local start_line = prompt.start_line
local end_line = prompt.end_line
local start_line_content = lines[start_line] or ""
local end_line_content = lines[end_line] or ""
local codetyper = require("codetyper")
local config = codetyper.get_config()
local before_tag = ""
local after_tag = ""
local open_pos = start_line_content:find(utils.escape_pattern(config.patterns.open_tag))
if open_pos and open_pos > 1 then
before_tag = start_line_content:sub(1, open_pos - 1)
end
local close_pos = end_line_content:find(utils.escape_pattern(config.patterns.close_tag))
if close_pos then
local after_close = close_pos + #config.patterns.close_tag
if after_close <= #end_line_content then
after_tag = end_line_content:sub(after_close)
end
end
local replacement_lines = vim.split(response, "\n", { plain = true })
if before_tag ~= "" and #replacement_lines > 0 then
replacement_lines[1] = before_tag .. replacement_lines[1]
end
if after_tag ~= "" and #replacement_lines > 0 then
replacement_lines[#replacement_lines] = replacement_lines[#replacement_lines] .. after_tag
end
vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, replacement_lines)
logs.info("Transform complete!")
utils.notify("Transform complete!", vim.log.levels.INFO)
end)
end
end)
end
--- Main command dispatcher
---@param args table Command arguments
local function coder_cmd(args)
@@ -264,6 +677,14 @@ local function coder_cmd(args)
["ask-toggle"] = cmd_ask_toggle,
["ask-clear"] = cmd_ask_clear,
gitignore = cmd_gitignore,
transform = cmd_transform,
["transform-cursor"] = cmd_transform_at_cursor,
agent = cmd_agent,
["agent-close"] = cmd_agent_close,
["agent-toggle"] = cmd_agent_toggle,
["agent-stop"] = cmd_agent_stop,
["type-toggle"] = cmd_type_toggle,
["logs-toggle"] = cmd_logs_toggle,
}
local cmd_fn = commands[subcommand]
@@ -283,6 +704,9 @@ function M.setup()
"open", "close", "toggle", "process", "status", "focus",
"tree", "tree-view", "reset", "gitignore",
"ask", "ask-close", "ask-toggle", "ask-clear",
"transform", "transform-cursor",
"agent", "agent-close", "agent-toggle", "agent-stop",
"type-toggle", "logs-toggle",
}
end,
desc = "Codetyper.nvim commands",
@@ -325,6 +749,86 @@ function M.setup()
vim.api.nvim_create_user_command("CoderAskClear", function()
cmd_ask_clear()
end, { desc = "Clear Ask history" })
-- Transform commands (inline /@ @/ tag replacement)
vim.api.nvim_create_user_command("CoderTransform", function()
cmd_transform()
end, { desc = "Transform all /@ @/ tags in current file" })
vim.api.nvim_create_user_command("CoderTransformCursor", function()
cmd_transform_at_cursor()
end, { desc = "Transform /@ @/ tag at cursor" })
vim.api.nvim_create_user_command("CoderTransformVisual", function(opts)
local start_line = opts.line1
local end_line = opts.line2
cmd_transform_range(start_line, end_line)
end, { range = true, desc = "Transform /@ @/ tags in visual selection" })
-- Agent commands
vim.api.nvim_create_user_command("CoderAgent", function()
cmd_agent()
end, { desc = "Open Agent panel" })
vim.api.nvim_create_user_command("CoderAgentToggle", function()
cmd_agent_toggle()
end, { desc = "Toggle Agent panel" })
vim.api.nvim_create_user_command("CoderAgentStop", function()
cmd_agent_stop()
end, { desc = "Stop running agent" })
-- Chat type switcher command
vim.api.nvim_create_user_command("CoderType", function()
cmd_type_toggle()
end, { desc = "Show Ask/Agent mode switcher" })
-- Logs panel command
vim.api.nvim_create_user_command("CoderLogs", function()
cmd_logs_toggle()
end, { desc = "Toggle logs panel" })
-- Index command - open coder companion for current file
vim.api.nvim_create_user_command("CoderIndex", function()
local autocmds = require("codetyper.autocmds")
autocmds.open_coder_companion()
end, { desc = "Open coder companion for current file" })
-- Setup default keymaps
M.setup_keymaps()
end
--- Setup default keymaps for transform commands
function M.setup_keymaps()
-- Visual mode: transform selected /@ @/ tags
vim.keymap.set("v", "<leader>ctt", ":<C-u>CoderTransformVisual<CR>", {
silent = true,
desc = "Coder: Transform selected tags"
})
-- Normal mode: transform tag at cursor
vim.keymap.set("n", "<leader>ctt", "<cmd>CoderTransformCursor<CR>", {
silent = true,
desc = "Coder: Transform tag at cursor"
})
-- Normal mode: transform all tags in file
vim.keymap.set("n", "<leader>ctT", "<cmd>CoderTransform<CR>", {
silent = true,
desc = "Coder: Transform all tags in file"
})
-- Agent keymaps
vim.keymap.set("n", "<leader>ca", "<cmd>CoderAgentToggle<CR>", {
silent = true,
desc = "Coder: Toggle Agent panel"
})
-- Index keymap - open coder companion
vim.keymap.set("n", "<leader>ci", "<cmd>CoderIndex<CR>", {
silent = true,
desc = "Coder: Open coder companion for file"
})
end
return M

View File

@@ -5,18 +5,30 @@ local M = {}
---@type CoderConfig
local defaults = {
llm = {
provider = "claude",
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",
},
ollama = {
host = "http://localhost:11434",
model = "codellama",
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

View File

@@ -1,8 +1,10 @@
---@mod codetyper Codetyper.nvim - AI-powered coding partner
---@brief [[
--- Codetyper.nvim is a Neovim plugin that acts as your coding partner.
--- It uses LLM APIs (Claude, Ollama) to help you write code faster
--- using special `.coder.*` files and inline prompt tags.
--- It uses LLM APIs (Claude, OpenAI, Gemini, Copilot, Ollama) to help you
--- write code faster using special `.coder.*` files and inline prompt tags.
--- Features an event-driven scheduler with confidence scoring and
--- completion-aware injection timing.
---@brief ]]
local M = {}
@@ -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)

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,28 @@
---@mod codetyper.llm LLM interface for Codetyper.nvim
local M = {}
local lang_map = require("codetyper.utils.langmap")
local utils = require("codetyper.utils")
--- Get the appropriate LLM client based on configuration
---@return table LLM client module
function M.get_client()
local codetyper = require("codetyper")
local config = codetyper.get_config()
local codetyper = require("codetyper")
local config = codetyper.get_config()
if config.llm.provider == "claude" then
return require("codetyper.llm.claude")
elseif config.llm.provider == "ollama" then
return require("codetyper.llm.ollama")
else
error("Unknown LLM provider: " .. config.llm.provider)
end
if config.llm.provider == "claude" then
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
end
--- Generate code from a prompt
@@ -24,31 +30,40 @@ end
---@param context table Context information (file content, language, etc.)
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
local client = M.get_client()
client.generate(prompt, context, callback)
local client = M.get_client()
client.generate(prompt, context, callback)
end
--- Build the system prompt for code generation
---@param context table Context information
---@return string System prompt
function M.build_system_prompt(context)
local prompts = require("codetyper.prompts")
-- Select appropriate system prompt based on context
local prompt_type = context.prompt_type or "code_generation"
local system_prompts = prompts.system
local system = system_prompts[prompt_type] or system_prompts.code_generation
-- Substitute variables
system = system:gsub("{{language}}", context.language or "unknown")
system = system:gsub("{{filepath}}", context.file_path or "unknown")
local prompts = require("codetyper.prompts")
if context.file_content then
system = system .. "\n\nExisting file content:\n```\n" .. context.file_content .. "\n```"
end
-- Select appropriate system prompt based on context
local prompt_type = context.prompt_type or "code_generation"
local system_prompts = prompts.system
return system
local system = system_prompts[prompt_type] or system_prompts.code_generation
-- Substitute variables
system = system:gsub("{{language}}", context.language or "unknown")
system = system:gsub("{{filepath}}", context.file_path or "unknown")
-- Add file content with analysis hints
if context.file_content and context.file_content ~= "" then
system = system .. "\n\n===== EXISTING FILE CONTENT (analyze and match this style) =====\n"
system = system .. context.file_content
system = system .. "\n===== END OF EXISTING FILE =====\n"
system = system .. "\nYour generated code MUST follow the exact patterns shown above."
else
system = system
.. "\n\nThis is a new/empty file. Generate clean, idiomatic "
.. (context.language or "code")
.. " following best practices."
end
return system
end
--- Build context for LLM request
@@ -56,46 +71,49 @@ end
---@param prompt_type string Type of prompt
---@return table Context object
function M.build_context(target_path, prompt_type)
local content = utils.read_file(target_path)
local ext = vim.fn.fnamemodify(target_path, ":e")
local content = utils.read_file(target_path)
local ext = vim.fn.fnamemodify(target_path, ":e")
-- Map extension to language
local lang_map = {
ts = "TypeScript",
tsx = "TypeScript React",
js = "JavaScript",
jsx = "JavaScript React",
py = "Python",
lua = "Lua",
go = "Go",
rs = "Rust",
rb = "Ruby",
java = "Java",
c = "C",
cpp = "C++",
cs = "C#",
}
return {
file_content = content,
language = lang_map[ext] or ext,
extension = ext,
prompt_type = prompt_type,
file_path = target_path,
}
return {
file_content = content,
language = lang_map[ext] or ext,
extension = ext,
prompt_type = prompt_type,
file_path = target_path,
}
end
--- Parse LLM response and extract code
---@param response string Raw LLM response
---@return string Extracted code
function M.extract_code(response)
-- Remove markdown code blocks if present
local code = response:gsub("```%w*\n?", ""):gsub("\n?```", "")
local code = response
-- Trim whitespace
code = code:match("^%s*(.-)%s*$")
-- Remove markdown code blocks with language tags (```typescript, ```javascript, etc.)
code = code:gsub("```%w+%s*\n", "")
code = code:gsub("```%w+%s*$", "")
code = code:gsub("^```%w*\n?", "")
code = code:gsub("\n?```%s*$", "")
code = code:gsub("\n```\n", "\n")
code = code:gsub("```", "")
return code
-- Remove common explanation prefixes that LLMs sometimes add
code = code:gsub("^Here.-:\n", "")
code = code:gsub("^Here's.-:\n", "")
code = code:gsub("^This.-:\n", "")
code = code:gsub("^The following.-:\n", "")
code = code:gsub("^Below.-:\n", "")
-- Remove common explanation suffixes
code = code:gsub("\n\nThis code.-$", "")
code = code:gsub("\n\nThe above.-$", "")
code = code:gsub("\n\nNote:.-$", "")
code = code:gsub("\n\nExplanation:.-$", "")
-- Trim leading/trailing whitespace but preserve internal formatting
code = code:match("^%s*(.-)%s*$") or code
return code
end
return M

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
---@mod codetyper.logs_panel Standalone logs panel for code generation
---
--- Shows real-time logs when generating code via /@ @/ prompts.
local M = {}
local logs = require("codetyper.agent.logs")
---@class LogsPanelState
---@field buf number|nil Buffer
---@field win number|nil Window
---@field is_open boolean Whether the panel is open
---@field listener_id number|nil Listener ID for logs
local state = {
buf = nil,
win = nil,
is_open = false,
listener_id = nil,
}
--- Namespace for highlights
local ns_logs = vim.api.nvim_create_namespace("codetyper_logs_panel")
--- Fixed width
local LOGS_WIDTH = 60
--- Add a log entry to the buffer
---@param entry table Log entry
local function add_log_entry(entry)
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
vim.schedule(function()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
-- Handle clear event
if entry.level == "clear" then
vim.bo[state.buf].modifiable = true
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
"Generation Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.buf].modifiable = false
return
end
vim.bo[state.buf].modifiable = true
local formatted = logs.format_entry(entry)
local lines = vim.api.nvim_buf_get_lines(state.buf, 0, -1, false)
local line_num = #lines
vim.api.nvim_buf_set_lines(state.buf, -1, -1, false, { formatted })
-- Apply highlighting based on level
local hl_map = {
info = "DiagnosticInfo",
debug = "Comment",
request = "DiagnosticWarn",
response = "DiagnosticOk",
tool = "DiagnosticHint",
error = "DiagnosticError",
}
local hl = hl_map[entry.level] or "Normal"
vim.api.nvim_buf_add_highlight(state.buf, ns_logs, hl, line_num, 0, -1)
vim.bo[state.buf].modifiable = false
-- Auto-scroll logs
if state.win and vim.api.nvim_win_is_valid(state.win) then
local new_count = vim.api.nvim_buf_line_count(state.buf)
pcall(vim.api.nvim_win_set_cursor, state.win, { new_count, 0 })
end
end)
end
--- Update the title with token counts
local function update_title()
if not state.win or not vim.api.nvim_win_is_valid(state.win) then
return
end
local prompt_tokens, response_tokens = logs.get_token_totals()
local provider, model = logs.get_provider_info()
if provider and state.buf and vim.api.nvim_buf_is_valid(state.buf) then
vim.bo[state.buf].modifiable = true
local title = string.format("%s | %d/%d tokens", (provider or ""):upper(), prompt_tokens, response_tokens)
vim.api.nvim_buf_set_lines(state.buf, 0, 1, false, { title })
vim.bo[state.buf].modifiable = false
end
end
--- Open the logs panel
function M.open()
if state.is_open then
return
end
-- Clear previous logs
logs.clear()
-- Create buffer
state.buf = vim.api.nvim_create_buf(false, true)
vim.bo[state.buf].buftype = "nofile"
vim.bo[state.buf].bufhidden = "hide"
vim.bo[state.buf].swapfile = false
-- Create window on the right
vim.cmd("botright vsplit")
state.win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(state.win, state.buf)
vim.api.nvim_win_set_width(state.win, LOGS_WIDTH)
-- Window options
vim.wo[state.win].number = false
vim.wo[state.win].relativenumber = false
vim.wo[state.win].signcolumn = "no"
vim.wo[state.win].wrap = true
vim.wo[state.win].linebreak = true
vim.wo[state.win].winfixwidth = true
vim.wo[state.win].cursorline = false
-- Set initial content
vim.bo[state.buf].modifiable = true
vim.api.nvim_buf_set_lines(state.buf, 0, -1, false, {
"Generation Logs",
string.rep("", LOGS_WIDTH - 2),
"",
})
vim.bo[state.buf].modifiable = false
-- Setup keymaps
local opts = { buffer = state.buf, noremap = true, silent = true }
vim.keymap.set("n", "q", M.close, opts)
vim.keymap.set("n", "<Esc>", M.close, opts)
-- Register log listener
state.listener_id = logs.add_listener(function(entry)
add_log_entry(entry)
if entry.level == "response" then
vim.schedule(update_title)
end
end)
state.is_open = true
-- Return focus to previous window
vim.cmd("wincmd p")
logs.info("Logs panel opened")
end
--- Close the logs panel
function M.close()
if not state.is_open then
return
end
-- Remove log listener
if state.listener_id then
logs.remove_listener(state.listener_id)
state.listener_id = nil
end
-- Close window
if state.win and vim.api.nvim_win_is_valid(state.win) then
pcall(vim.api.nvim_win_close, state.win, true)
end
-- Reset state
state.buf = nil
state.win = nil
state.is_open = false
end
--- Toggle the logs panel
function M.toggle()
if state.is_open then
M.close()
else
M.open()
end
end
--- Check if panel is open
---@return boolean
function M.is_open()
return state.is_open
end
--- Ensure panel is open (call before starting generation)
function M.ensure_open()
if not state.is_open then
M.open()
end
end
return M

View File

@@ -0,0 +1,81 @@
---@mod codetyper.prompts.agent Agent prompts for Codetyper.nvim
---
--- System prompts for the agentic mode with tool use.
local M = {}
--- System prompt for agent mode
M.system = [[You are an AI coding agent integrated into Neovim via Codetyper.nvim.
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: 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
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 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 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 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 = [[Provide a concise summary of what was changed.
Include:
- Files that were read or modified
- The nature of the changes (high-level)
- Any follow-up steps or recommendations, if applicable
Do NOT restate tool output verbatim.
]]
return M

View File

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

View File

@@ -1,93 +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}}
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 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}}
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}}
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}}
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}}
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}}
Constraints:
- Operate ONLY on the provided scope
- Preserve existing structure and intent
- Do NOT modify code outside the target region
- Do NOT add explanations, comments, or formatting changes unless requested
OUTPUT ONLY THE RAW CODE THAT REPLACES THE TARGET SCOPE. No explanations, no markdown, no code fences.
]]
return M

View File

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

View File

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

View File

@@ -4,93 +4,121 @@
local M = {}
--- Base system prompt for code generation
M.code_generation = [[You are an expert code generation assistant integrated into Neovim via Codetyper.nvim.
Your task is to generate high-quality, production-ready code based on the user's prompt.
--- Base system prompt for code generation / modification
M.code_generation = [[You are an expert code assistant integrated into Neovim via Codetyper.nvim.
CRITICAL RULES:
1. Output ONLY the code - no explanations, no markdown code blocks, no comments about what you did
2. Match the coding style, conventions, and patterns of the existing file
3. Use proper indentation and formatting for the language
4. Follow best practices for the specific language/framework
5. Preserve existing functionality unless explicitly asked to change it
6. Use meaningful variable and function names
7. Handle edge cases and errors appropriately
You are operating on a SPECIFIC, LIMITED REGION of an existing {{language}} file.
Your output will REPLACE that region exactly.
Language: {{language}}
File: {{filepath}}
ABSOLUTE RULES - FOLLOW STRICTLY:
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
Context:
- Language: {{language}}
- File: {{filepath}}
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 via Codetyper.nvim.
Your task is to refactor code while maintaining its functionality.
--- System prompt for scoped refactoring
M.refactor = [[You are an expert refactoring assistant integrated into Neovim via Codetyper.nvim.
CRITICAL RULES:
1. Output ONLY the refactored code - no explanations
2. Preserve ALL existing functionality
3. Improve code quality, readability, and maintainability
4. Follow SOLID principles and best practices
5. Keep the same coding style as the original
6. Do not add new features unless explicitly requested
7. Optimize performance where possible without sacrificing readability
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. 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: 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 via Codetyper.nvim.
Your task is to generate clear, comprehensive documentation for code.
--- System prompt for documentation generation
M.document = [[You are a documentation assistant integrated into Neovim via Codetyper.nvim.
CRITICAL RULES:
1. Output ONLY the documentation/comments - ready to be inserted into code
2. Use the appropriate documentation format for the language:
- JavaScript/TypeScript: JSDoc
- Python: Docstrings (Google or NumPy style)
- Lua: LuaDoc/EmmyLua
- Go: GoDoc
- Rust: RustDoc
- Java: Javadoc
3. Document all parameters, return values, and exceptions
4. Include usage examples where helpful
5. Be concise but complete
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 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 (---)
- Go: GoDoc comments
- Rust: RustDoc (///)
- Ruby: YARD
- PHP: PHPDoc
- Java/Kotlin: Javadoc
- C/C++: Doxygen
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.
]]
--- System prompt for test generation
M.test = [[You are a test generation expert integrated into Neovim via Codetyper.nvim.
Your task is to generate comprehensive unit tests for the provided code.
M.test = [[You are a test generation assistant integrated into Neovim via Codetyper.nvim.
CRITICAL RULES:
1. Output ONLY the test code - no explanations
2. Use the appropriate testing framework for the language:
- JavaScript/TypeScript: Jest or Vitest
- Python: pytest
You are generating NEW unit tests for existing {{language}} code.
ABSOLUTE RULES - FOLLOW STRICTLY:
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
- Go: testing package
- Rust: built-in tests
3. Cover happy paths, edge cases, and error scenarios
4. Use descriptive test names
5. Follow AAA pattern: Arrange, Act, Assert
6. Mock external dependencies appropriately
- Rust: built-in #[test]
- Ruby: RSpec or Minitest
- PHP: PHPUnit
- Java/Kotlin: JUnit
- C/C++: Google Test or Catch2
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.
]]
return M

View File

@@ -10,6 +10,19 @@ local CODER_FOLDER = ".coder"
--- Name of the tree log file
local TREE_LOG_FILE = "tree.log"
--- Name of the settings file
local SETTINGS_FILE = "settings.json"
--- Default settings for the coder folder
local DEFAULT_SETTINGS = {
["editor.fontSize"] = 14,
["editor.tabSize"] = 2,
["files.autoSave"] = "afterDelay",
["files.autoSaveDelay"] = 1000,
["terminal.integrated.fontSize"] = 14,
["workbench.colorTheme"] = "Default Dark+",
}
--- Get the path to the .coder folder
---@return string|nil Path to .coder folder or nil
function M.get_coder_folder()
@@ -30,6 +43,57 @@ function M.get_tree_log_path()
return coder_folder .. "/" .. TREE_LOG_FILE
end
--- Get the path to the settings.json file
---@return string|nil Path to settings.json or nil
function M.get_settings_path()
local coder_folder = M.get_coder_folder()
if not coder_folder then
return nil
end
return coder_folder .. "/" .. SETTINGS_FILE
end
--- Ensure settings.json exists with default settings
---@return boolean Success status
function M.ensure_settings()
local settings_path = M.get_settings_path()
if not settings_path then
return false
end
-- Check if file already exists
local stat = vim.loop.fs_stat(settings_path)
if stat then
return true -- File already exists, don't overwrite
end
-- Create settings file with defaults
local json_content = vim.fn.json_encode(DEFAULT_SETTINGS)
-- Pretty print the JSON
local ok, pretty_json = pcall(function()
return vim.fn.system({ "python3", "-m", "json.tool" }, json_content)
end)
if not ok or vim.v.shell_error ~= 0 then
-- Fallback to simple formatting if python not available
pretty_json = "{\n"
local keys = vim.tbl_keys(DEFAULT_SETTINGS)
table.sort(keys)
for i, key in ipairs(keys) do
local value = DEFAULT_SETTINGS[key]
local value_str = type(value) == "string" and ('"' .. value .. '"') or tostring(value)
pretty_json = pretty_json .. ' "' .. key .. '": ' .. value_str
if i < #keys then
pretty_json = pretty_json .. ","
end
pretty_json = pretty_json .. "\n"
end
pretty_json = pretty_json .. "}\n"
end
return utils.write_file(settings_path, pretty_json)
end
--- Ensure .coder folder exists
---@return boolean Success status
function M.ensure_coder_folder()
@@ -198,10 +262,51 @@ function M.update_tree_log()
return false
end
--- Cache to track initialized projects (by root path)
local initialized_projects = {}
--- Check if project is already initialized
---@param root string Project root path
---@return boolean
local function is_project_initialized(root)
return initialized_projects[root] == true
end
--- Initialize tree logging (called on setup)
function M.setup()
---@param force? boolean Force re-initialization even if cached
---@return boolean success
function M.setup(force)
local coder_folder = M.get_coder_folder()
if not coder_folder then
return false
end
local root = utils.get_project_root()
if not root then
return false
end
-- Skip if already initialized (unless forced)
if not force and is_project_initialized(root) then
return true
end
-- Ensure .coder folder exists
if not M.ensure_coder_folder() then
utils.notify("Failed to create .coder folder", vim.log.levels.ERROR)
return false
end
-- Create settings.json with defaults if it doesn't exist
M.ensure_settings()
-- Create initial tree log
M.update_tree_log()
-- Mark project as initialized
initialized_projects[root] = true
return true
end
--- Get file statistics from tree

View File

@@ -17,7 +17,8 @@ function M.get_project_root()
end
found = vim.fn.finddir(marker, current .. ";")
if found ~= "" then
return vim.fn.fnamemodify(found, ":p:h")
-- For directories, :p:h gives the dir itself, so we need :p:h:h to get parent
return vim.fn.fnamemodify(found, ":p:h:h")
end
end

View File

@@ -0,0 +1,75 @@
local lang_map = {
-- JavaScript/TypeScript
ts = "TypeScript",
tsx = "TypeScript React (TSX)",
js = "JavaScript",
jsx = "JavaScript React (JSX)",
mjs = "JavaScript (ESM)",
cjs = "JavaScript (CommonJS)",
-- Python
py = "Python",
pyw = "Python",
pyx = "Cython",
-- Systems languages
c = "C",
h = "C Header",
cpp = "C++",
hpp = "C++ Header",
cc = "C++",
cxx = "C++",
rs = "Rust",
go = "Go",
-- JVM languages
java = "Java",
kt = "Kotlin",
kts = "Kotlin Script",
scala = "Scala",
clj = "Clojure",
-- Web
html = "HTML",
css = "CSS",
scss = "SCSS",
sass = "Sass",
less = "Less",
vue = "Vue",
svelte = "Svelte",
-- Scripting
lua = "Lua",
rb = "Ruby",
php = "PHP",
pl = "Perl",
sh = "Shell (Bash)",
bash = "Bash",
zsh = "Zsh",
fish = "Fish",
ps1 = "PowerShell",
-- .NET
cs = "C#",
fs = "F#",
vb = "Visual Basic",
-- Data/Config
json = "JSON",
yaml = "YAML",
yml = "YAML",
toml = "TOML",
xml = "XML",
sql = "SQL",
graphql = "GraphQL",
-- Other
swift = "Swift",
dart = "Dart",
ex = "Elixir",
exs = "Elixir Script",
erl = "Erlang",
hs = "Haskell",
ml = "OCaml",
r = "R",
jl = "Julia",
nim = "Nim",
zig = "Zig",
v = "V",
md = "Markdown",
mdx = "MDX",
}
return lang_map

View File

@@ -1,121 +1,148 @@
-- Codetyper.nvim - AI-powered coding partner for Neovim
-- Plugin loader
local g = vim.g
local fn = vim.fn
local api = vim.api
local cmd = vim.cmd
-- Prevent loading twice
if vim.g.loaded_codetyper then
return
if g.loaded_codetyper then
return
end
vim.g.loaded_codetyper = true
g.loaded_codetyper = true
-- Minimum Neovim version check
if vim.fn.has("nvim-0.8.0") == 0 then
vim.api.nvim_err_writeln("Codetyper.nvim requires Neovim 0.8.0 or higher")
return
if fn.has("nvim-0.8.0") == 0 then
api.nvim_err_writeln("Codetyper.nvim requires Neovim 0.8.0 or higher")
return
end
--- Initialize codetyper plugin fully
--- Creates .coder folder, settings.json, tree.log, .gitignore
--- Also registers autocmds for /@ @/ prompt detection
---@return boolean success
local function init_coder_files()
local ok, err = pcall(function()
-- Full plugin initialization (includes config, commands, autocmds, tree, gitignore)
local codetyper = require("codetyper")
if not codetyper.is_initialized() then
codetyper.setup()
end
end)
if not ok then
vim.notify("[Codetyper] Failed to initialize: " .. tostring(err), vim.log.levels.ERROR)
return false
end
return true
end
-- Initialize .coder folder and tree.log on project open
vim.api.nvim_create_autocmd("VimEnter", {
callback = function()
-- Delay slightly to ensure cwd is set
vim.defer_fn(function()
local tree = require("codetyper.tree")
tree.setup()
-- Also ensure gitignore is updated
local gitignore = require("codetyper.gitignore")
gitignore.ensure_ignored()
end, 100)
end,
desc = "Initialize Codetyper .coder folder on startup",
api.nvim_create_autocmd("VimEnter", {
callback = function()
-- Delay slightly to ensure cwd is set
vim.defer_fn(function()
init_coder_files()
end, 100)
end,
desc = "Initialize Codetyper .coder folder on startup",
})
-- Also initialize on directory change
vim.api.nvim_create_autocmd("DirChanged", {
callback = function()
vim.defer_fn(function()
local tree = require("codetyper.tree")
tree.setup()
local gitignore = require("codetyper.gitignore")
gitignore.ensure_ignored()
end, 100)
end,
desc = "Initialize Codetyper .coder folder on directory change",
api.nvim_create_autocmd("DirChanged", {
callback = function()
vim.defer_fn(function()
init_coder_files()
end, 100)
end,
desc = "Initialize Codetyper .coder folder on directory change",
})
-- Auto-initialize when opening a coder file (for nvim-tree, telescope, etc.)
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile", "BufEnter" }, {
pattern = "*.coder.*",
callback = function()
-- Initialize plugin if not already done
local codetyper = require("codetyper")
if not codetyper.is_initialized() then
codetyper.setup()
end
end,
desc = "Auto-initialize Codetyper when opening coder files",
api.nvim_create_autocmd({ "BufRead", "BufNewFile", "BufEnter" }, {
pattern = "*.coder.*",
callback = function()
-- Initialize plugin if not already done
local codetyper = require("codetyper")
if not codetyper.is_initialized() then
codetyper.setup()
end
end,
desc = "Auto-initialize Codetyper when opening coder files",
})
-- Lazy-load the plugin on first command usage
vim.api.nvim_create_user_command("Coder", function(opts)
require("codetyper").setup()
-- Re-execute the command now that plugin is loaded
vim.cmd("Coder " .. (opts.args or ""))
api.nvim_create_user_command("Coder", function(opts)
require("codetyper").setup()
-- Re-execute the command now that plugin is loaded
cmd("Coder " .. (opts.args or ""))
end, {
nargs = "?",
complete = function()
return {
"open", "close", "toggle", "process", "status", "focus",
"tree", "tree-view", "reset", "gitignore",
"ask", "ask-close", "ask-toggle", "ask-clear",
}
end,
desc = "Codetyper.nvim commands",
nargs = "?",
complete = function()
return {
"open",
"close",
"toggle",
"process",
"status",
"focus",
"tree",
"tree-view",
"reset",
"gitignore",
"ask",
"ask-close",
"ask-toggle",
"ask-clear",
}
end,
desc = "Codetyper.nvim commands",
})
-- Lazy-load aliases
vim.api.nvim_create_user_command("CoderOpen", function()
require("codetyper").setup()
vim.cmd("CoderOpen")
api.nvim_create_user_command("CoderOpen", function()
require("codetyper").setup()
cmd("CoderOpen")
end, { desc = "Open Coder view" })
vim.api.nvim_create_user_command("CoderClose", function()
require("codetyper").setup()
vim.cmd("CoderClose")
api.nvim_create_user_command("CoderClose", function()
require("codetyper").setup()
cmd("CoderClose")
end, { desc = "Close Coder view" })
vim.api.nvim_create_user_command("CoderToggle", function()
require("codetyper").setup()
vim.cmd("CoderToggle")
api.nvim_create_user_command("CoderToggle", function()
require("codetyper").setup()
cmd("CoderToggle")
end, { desc = "Toggle Coder view" })
vim.api.nvim_create_user_command("CoderProcess", function()
require("codetyper").setup()
vim.cmd("CoderProcess")
api.nvim_create_user_command("CoderProcess", function()
require("codetyper").setup()
cmd("CoderProcess")
end, { desc = "Process prompt and generate code" })
vim.api.nvim_create_user_command("CoderTree", function()
require("codetyper").setup()
vim.cmd("CoderTree")
api.nvim_create_user_command("CoderTree", function()
require("codetyper").setup()
cmd("CoderTree")
end, { desc = "Refresh tree.log" })
vim.api.nvim_create_user_command("CoderTreeView", function()
require("codetyper").setup()
vim.cmd("CoderTreeView")
api.nvim_create_user_command("CoderTreeView", function()
require("codetyper").setup()
cmd("CoderTreeView")
end, { desc = "View tree.log" })
-- Ask panel commands
vim.api.nvim_create_user_command("CoderAsk", function()
require("codetyper").setup()
vim.cmd("CoderAsk")
api.nvim_create_user_command("CoderAsk", function()
require("codetyper").setup()
cmd("CoderAsk")
end, { desc = "Open Ask panel" })
vim.api.nvim_create_user_command("CoderAskToggle", function()
require("codetyper").setup()
vim.cmd("CoderAskToggle")
api.nvim_create_user_command("CoderAskToggle", function()
require("codetyper").setup()
cmd("CoderAskToggle")
end, { desc = "Toggle Ask panel" })
vim.api.nvim_create_user_command("CoderAskClear", function()
require("codetyper").setup()
vim.cmd("CoderAskClear")
api.nvim_create_user_command("CoderAskClear", function()
require("codetyper").setup()
cmd("CoderAskClear")
end, { desc = "Clear Ask history" })

48
tests/minimal_init.lua Normal file
View File

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

62
tests/run_tests.sh Executable file
View File

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

View File

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,141 @@
---@diagnostic disable: undefined-global
-- Tests for lua/codetyper/parser.lua
describe("parser", function()
local parser = require("codetyper.parser")
describe("find_prompts", function()
it("should find single-line prompt", function()
local content = "/@ create a function @/"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.equals(" create a function ", prompts[1].content)
assert.equals(1, prompts[1].start_line)
assert.equals(1, prompts[1].end_line)
end)
it("should find multi-line prompt", function()
local content = [[
/@ create a function
that validates email
addresses @/
]]
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.is_true(prompts[1].content:match("validates email"))
assert.equals(2, prompts[1].start_line)
assert.equals(4, prompts[1].end_line)
end)
it("should find multiple prompts", function()
local content = [[
/@ first prompt @/
some code here
/@ second prompt @/
more code
/@ third prompt
multiline @/
]]
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(3, #prompts)
assert.equals(" first prompt ", prompts[1].content)
assert.equals(" second prompt ", prompts[2].content)
assert.is_true(prompts[3].content:match("third prompt"))
end)
it("should return empty table when no prompts found", function()
local content = "just some regular code\nno prompts here"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(0, #prompts)
end)
it("should handle prompts with special characters", function()
local content = "/@ add (function) with [brackets] @/"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.is_true(prompts[1].content:match("function"))
assert.is_true(prompts[1].content:match("brackets"))
end)
it("should handle empty prompt content", function()
local content = "/@ @/"
local prompts = parser.find_prompts(content, "/@", "@/")
assert.equals(1, #prompts)
assert.equals(" ", prompts[1].content)
end)
it("should handle custom tags", function()
local content = "<!-- prompt: create button -->"
local prompts = parser.find_prompts(content, "<!-- prompt:", "-->")
assert.equals(1, #prompts)
assert.is_true(prompts[1].content:match("create button"))
end)
end)
describe("detect_prompt_type", function()
it("should detect refactor type", function()
assert.equals("refactor", parser.detect_prompt_type("refactor this code"))
assert.equals("refactor", parser.detect_prompt_type("REFACTOR the function"))
end)
it("should detect add type", function()
assert.equals("add", parser.detect_prompt_type("add a new function"))
assert.equals("add", parser.detect_prompt_type("create a component"))
assert.equals("add", parser.detect_prompt_type("implement sorting algorithm"))
end)
it("should detect document type", function()
assert.equals("document", parser.detect_prompt_type("document this function"))
assert.equals("document", parser.detect_prompt_type("add jsdoc comments"))
assert.equals("document", parser.detect_prompt_type("comment the code"))
end)
it("should detect explain type", function()
assert.equals("explain", parser.detect_prompt_type("explain this code"))
assert.equals("explain", parser.detect_prompt_type("what does this do"))
assert.equals("explain", parser.detect_prompt_type("how does this work"))
end)
it("should return generic for unknown types", function()
assert.equals("generic", parser.detect_prompt_type("do something"))
assert.equals("generic", parser.detect_prompt_type("make it better"))
end)
end)
describe("clean_prompt", function()
it("should trim whitespace", function()
assert.equals("hello", parser.clean_prompt(" hello "))
assert.equals("hello", parser.clean_prompt("\n\nhello\n\n"))
end)
it("should normalize multiple newlines", function()
local input = "line1\n\n\n\nline2"
local expected = "line1\n\nline2"
assert.equals(expected, parser.clean_prompt(input))
end)
it("should preserve single newlines", function()
local input = "line1\nline2\nline3"
assert.equals(input, parser.clean_prompt(input))
end)
end)
describe("has_closing_tag", function()
it("should return true when closing tag exists", function()
assert.is_true(parser.has_closing_tag("some text @/", "@/"))
assert.is_true(parser.has_closing_tag("@/", "@/"))
end)
it("should return false when closing tag missing", function()
assert.is_false(parser.has_closing_tag("some text", "@/"))
assert.is_false(parser.has_closing_tag("", "@/"))
end)
end)
end)

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

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

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

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

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

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