Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbd88993e7 | |||
| 6b25aef917 | |||
| 8a3ee81c3f | |||
| 6268a57498 | |||
| 6b71c76517 | |||
| 73c56d2f6d | |||
| 2989fb5f14 | |||
| 9c99944f26 | |||
| 29f321995d | |||
| 9d52b9364f | |||
| 86504eec55 | |||
| d75196b4f9 | |||
| 5b90a8e5f9 | |||
| bbdc1aa849 | |||
| d11a99ee61 | |||
| 8130a78ed5 | |||
| fe04e624db |
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
2
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
blank_issues_enabled: true
|
||||
version: 2.1
|
||||
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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 ...
|
||||
24
.github/workflows/close-stale-issues-and-prs.yaml
vendored
Normal file
24
.github/workflows/close-stale-issues-and-prs.yaml
vendored
Normal 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
86
.github/workflows/lua.yaml
vendored
Normal 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
38
.github/workflows/pre-commit.yaml
vendored
Normal 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
185
.github/workflows/release.yaml
vendored
Normal 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
31
.github/workflows/rust.yaml
vendored
Normal 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
49
.gitignore
vendored
Normal 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
|
||||
115
CHANGELOG.md
115
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
646
README.md
@@ -7,29 +7,36 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🪟 Split View**: Work with your code and prompts side by side
|
||||
- **💬 Ask Panel**: Chat interface for questions and explanations (like avante.nvim)
|
||||
- **🏷️ Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts
|
||||
- **🤖 Multiple LLM Providers**: Support for Claude API and Ollama (local)
|
||||
- **📝 Smart Injection**: Automatically detects prompt type (refactor, add, document)
|
||||
- **🔒 Git Integration**: Automatically adds `.coder.*` files and `.coder/` folder to `.gitignore`
|
||||
- **🌳 Project Tree Logging**: Automatically maintains a `tree.log` tracking your project structure
|
||||
- **⚡ Lazy Loading**: Only loads when you need it
|
||||
- 📐 **Split View**: Work with your code and prompts side by side
|
||||
- 💬 **Ask Panel**: Chat interface for questions and explanations
|
||||
- 🤖 **Agent Mode**: Autonomous coding agent with tool use (read, edit, write, bash)
|
||||
- 🏷️ **Tag-based Prompts**: Use `/@` and `@/` tags to write natural language prompts
|
||||
- ⚡ **Transform Commands**: Transform prompts inline without leaving your file
|
||||
- 🔌 **Multiple LLM Providers**: Claude, OpenAI, Gemini, Copilot, and Ollama (local)
|
||||
- 📋 **Event-Driven Scheduler**: Queue-based processing with optimistic execution
|
||||
- 🎯 **Tree-sitter Scope Resolution**: Smart context extraction for functions/methods
|
||||
- 🧠 **Intent Detection**: Understands complete, refactor, fix, add, document intents
|
||||
- 📊 **Confidence Scoring**: Automatic escalation from local to remote LLMs
|
||||
- 🛡️ **Completion-Aware**: Safe injection that doesn't fight with autocomplete
|
||||
- 📁 **Auto-Index**: Automatically create coder companion files on file open
|
||||
- 📜 **Logs Panel**: Real-time visibility into LLM requests and token usage
|
||||
- 🔒 **Git Integration**: Automatically adds `.coder.*` files to `.gitignore`
|
||||
- 🌳 **Project Tree Logging**: Maintains a `tree.log` tracking your project structure
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Requirements](#-requirements)
|
||||
- [Installation](#-installation)
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Configuration](#%EF%B8%8F-configuration)
|
||||
- [Configuration](#-configuration)
|
||||
- [LLM Providers](#-llm-providers)
|
||||
- [Commands Reference](#-commands-reference)
|
||||
- [Usage Guide](#-usage-guide)
|
||||
- [How It Works](#%EF%B8%8F-how-it-works)
|
||||
- [Keymaps](#-keymaps-suggested)
|
||||
- [Agent Mode](#-agent-mode)
|
||||
- [Keymaps](#-keymaps)
|
||||
- [Health Check](#-health-check)
|
||||
- [Contributing](#-contributing)
|
||||
|
||||
---
|
||||
|
||||
@@ -37,7 +44,7 @@
|
||||
|
||||
- Neovim >= 0.8.0
|
||||
- curl (for API calls)
|
||||
- Claude API key **OR** Ollama running locally
|
||||
- One of: Claude API key, OpenAI API key, Gemini API key, GitHub Copilot, or Ollama running locally
|
||||
|
||||
---
|
||||
|
||||
@@ -48,16 +55,16 @@
|
||||
```lua
|
||||
{
|
||||
"cargdev/codetyper.nvim",
|
||||
cmd = { "Coder", "CoderOpen", "CoderToggle" },
|
||||
cmd = { "Coder", "CoderOpen", "CoderToggle", "CoderAgent" },
|
||||
keys = {
|
||||
{ "<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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
346
llms.txt
@@ -4,7 +4,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Codetyper.nvim is a Neovim plugin written in Lua that acts as an AI-powered coding partner. It integrates with LLM APIs (Claude, Ollama) to help developers write code faster using a unique prompt-based workflow.
|
||||
Codetyper.nvim is a Neovim plugin written in Lua that acts as an AI-powered coding partner. It integrates with multiple LLM APIs (Claude, OpenAI, Gemini, Copilot, Ollama) to help developers write code faster using a unique prompt-based workflow.
|
||||
|
||||
## Core Concept
|
||||
|
||||
@@ -27,15 +27,39 @@ lua/codetyper/
|
||||
├── commands.lua # Vim command definitions (:Coder, :CoderOpen, etc.)
|
||||
├── window.lua # Split window management (open, close, toggle)
|
||||
├── parser.lua # Parses /@ @/ tags from buffer content
|
||||
├── gitignore.lua # Manages .gitignore entries for coder files and .coder/ folder
|
||||
├── autocmds.lua # Autocommands for tag detection, filetype, tree updates
|
||||
├── gitignore.lua # Manages .gitignore entries for coder files
|
||||
├── autocmds.lua # Autocommands for tag detection, filetype, auto-index
|
||||
├── inject.lua # Code injection strategies
|
||||
├── health.lua # Health check for :checkhealth
|
||||
├── tree.lua # Project tree logging (.coder/tree.log)
|
||||
└── llm/
|
||||
├── init.lua # LLM interface, provider selection
|
||||
├── claude.lua # Claude API client (Anthropic)
|
||||
└── ollama.lua # Ollama API client (local LLMs)
|
||||
├── logs_panel.lua # Standalone logs panel UI
|
||||
├── llm/
|
||||
│ ├── init.lua # LLM interface, provider selection
|
||||
│ ├── claude.lua # Claude API client (Anthropic)
|
||||
│ ├── openai.lua # OpenAI API client (with custom endpoint support)
|
||||
│ ├── gemini.lua # Google Gemini API client
|
||||
│ ├── copilot.lua # GitHub Copilot client (uses OAuth from copilot.lua/vim)
|
||||
│ └── ollama.lua # Ollama API client (local LLMs)
|
||||
├── agent/
|
||||
│ ├── init.lua # Agent system entry point
|
||||
│ ├── ui.lua # Agent panel UI
|
||||
│ ├── logs.lua # Logging system with listeners
|
||||
│ ├── tools.lua # Tool definitions (read_file, edit_file, write_file, bash)
|
||||
│ ├── executor.lua # Tool execution logic
|
||||
│ ├── parser.lua # Parse tool calls from LLM responses
|
||||
│ ├── queue.lua # Event queue with priority heap
|
||||
│ ├── patch.lua # Patch candidates with staleness detection
|
||||
│ ├── confidence.lua # Response confidence scoring heuristics
|
||||
│ ├── worker.lua # Async LLM worker wrapper
|
||||
│ ├── scheduler.lua # Event scheduler with completion-awareness
|
||||
│ ├── scope.lua # Tree-sitter scope resolution
|
||||
│ └── intent.lua # Intent detection from prompts
|
||||
├── ask/
|
||||
│ ├── init.lua # Ask panel entry point
|
||||
│ └── ui.lua # Ask panel UI (chat interface)
|
||||
└── prompts/
|
||||
├── init.lua # System prompts for code generation
|
||||
└── agent.lua # Agent-specific prompts and tool instructions
|
||||
```
|
||||
|
||||
## .coder/ Folder
|
||||
@@ -47,51 +71,240 @@ The plugin automatically creates and maintains a `.coder/` folder in your projec
|
||||
└── tree.log # Project structure, auto-updated on file changes
|
||||
```
|
||||
|
||||
The `tree.log` contains:
|
||||
- Project name and timestamp
|
||||
- Full directory tree with file type icons
|
||||
- Automatically ignores: hidden files, node_modules, .git, build folders, coder files
|
||||
## Key Features
|
||||
|
||||
Tree updates are triggered by:
|
||||
- `BufWritePost` - When files are saved
|
||||
- `BufNewFile` - When new files are created
|
||||
- `BufDelete` - When files are deleted
|
||||
- `DirChanged` - When changing directories
|
||||
### 1. Multiple LLM Providers
|
||||
|
||||
Updates are debounced (1 second) to prevent excessive writes.
|
||||
|
||||
## Key Functions
|
||||
|
||||
### Setup
|
||||
```lua
|
||||
require("codetyper").setup({
|
||||
llm = { provider = "claude" | "ollama", ... },
|
||||
window = { width = 0.4, position = "left" },
|
||||
patterns = { open_tag = "/@", close_tag = "@/" },
|
||||
auto_gitignore = true,
|
||||
})
|
||||
llm = {
|
||||
provider = "claude", -- "claude", "openai", "gemini", "copilot", "ollama"
|
||||
claude = { api_key = nil, model = "claude-sonnet-4-20250514" },
|
||||
openai = { api_key = nil, model = "gpt-4o", endpoint = nil },
|
||||
gemini = { api_key = nil, model = "gemini-2.0-flash" },
|
||||
copilot = { model = "gpt-4o" },
|
||||
ollama = { host = "http://localhost:11434", model = "deepseek-coder:6.7b" },
|
||||
}
|
||||
```
|
||||
|
||||
### Commands
|
||||
### 2. Agent Mode
|
||||
|
||||
Autonomous coding assistant with tool access:
|
||||
|
||||
- `read_file` - Read file contents
|
||||
- `edit_file` - Edit files with find/replace
|
||||
- `write_file` - Create or overwrite files
|
||||
- `bash` - Execute shell commands
|
||||
|
||||
### 3. Transform Commands
|
||||
|
||||
Transform `/@ @/` tags inline without split view:
|
||||
|
||||
- `:CoderTransform` - Transform all tags in file
|
||||
- `:CoderTransformCursor` - Transform tag at cursor
|
||||
- `:CoderTransformVisual` - Transform selected tags
|
||||
|
||||
### 4. Auto-Index
|
||||
|
||||
Automatically create coder companion files when opening source files:
|
||||
|
||||
```lua
|
||||
auto_index = true -- disabled by default
|
||||
```
|
||||
|
||||
### 5. Logs Panel
|
||||
|
||||
Real-time visibility into LLM operations with token usage tracking.
|
||||
|
||||
### 6. Event-Driven Scheduler
|
||||
|
||||
Prompts are treated as events, not commands:
|
||||
|
||||
```
|
||||
User types /@...@/ → Event queued → Scheduler dispatches → Worker processes → Patch created → Safe injection
|
||||
```
|
||||
|
||||
**Key concepts:**
|
||||
|
||||
- **PromptEvent**: Captures buffer state (changedtick, content hash) at prompt time
|
||||
- **Optimistic Execution**: Ollama as fast scout, escalate to remote LLMs if confidence low
|
||||
- **Confidence Scoring**: 5 heuristics (length, uncertainty, syntax, repetition, truncation)
|
||||
- **Staleness Detection**: Discard patches if buffer changed during generation
|
||||
- **Completion Safety**: Defer injection while autocomplete popup visible
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```lua
|
||||
scheduler = {
|
||||
enabled = true, -- Enable event-driven mode
|
||||
ollama_scout = true, -- Use Ollama first
|
||||
escalation_threshold = 0.7, -- Below this → escalate
|
||||
max_concurrent = 2, -- Parallel workers
|
||||
completion_delay_ms = 100, -- Wait after popup closes
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Tree-sitter Scope Resolution
|
||||
|
||||
Prompts automatically resolve to their enclosing function/method/class:
|
||||
|
||||
```lua
|
||||
function foo()
|
||||
/@ complete this function @/ -- Resolves to `foo`
|
||||
end
|
||||
```
|
||||
|
||||
**Scope types:** `function`, `method`, `class`, `block`, `file`
|
||||
|
||||
For replacement intents (complete, refactor, fix), the entire scope is extracted
|
||||
and sent to the LLM, then replaced with the transformed version.
|
||||
|
||||
### 8. Intent Detection
|
||||
|
||||
The system parses prompts to detect user intent:
|
||||
|
||||
| Intent | Keywords | Action |
|
||||
|--------|----------|--------|
|
||||
| complete | complete, finish, implement | replace |
|
||||
| refactor | refactor, rewrite, simplify | replace |
|
||||
| fix | fix, repair, debug, bug | replace |
|
||||
| add | add, create, insert, new | insert |
|
||||
| document | document, comment, jsdoc | replace |
|
||||
| test | test, spec, unit test | append |
|
||||
| optimize | optimize, performance, faster | replace |
|
||||
| explain | explain, what, how, why | none |
|
||||
|
||||
### 9. Tag Precedence
|
||||
|
||||
Multiple tags in the same scope follow "first tag wins" rule:
|
||||
- Earlier (by line number) unresolved tag processes first
|
||||
- Later tags in same scope are skipped with warning
|
||||
- Different scopes process independently
|
||||
|
||||
## Commands
|
||||
|
||||
### Main Commands
|
||||
- `:Coder open` - Opens split view with coder file
|
||||
- `:Coder close` - Closes the split
|
||||
- `:Coder toggle` - Toggles the view
|
||||
- `:Coder process` - Manually triggers code generation
|
||||
- `:Coder status` - Shows configuration status and project stats
|
||||
- `:Coder tree` - Manually refresh tree.log
|
||||
- `:Coder tree-view` - Open tree.log in split view
|
||||
|
||||
### Prompt Tags
|
||||
- Opening tag: `/@`
|
||||
- Closing tag: `@/`
|
||||
- Content between tags is the prompt sent to LLM
|
||||
### Ask Panel
|
||||
- `:CoderAsk` - Open Ask panel
|
||||
- `:CoderAskToggle` - Toggle Ask panel
|
||||
- `:CoderAskClear` - Clear chat history
|
||||
|
||||
### Prompt Types (Auto-detected)
|
||||
- `refactor` - Modifies existing code
|
||||
- `add` - Adds new code at cursor/end
|
||||
- `document` - Adds documentation/comments
|
||||
- `explain` - Explanations (no code injection)
|
||||
- `generic` - User chooses injection method
|
||||
### Agent Mode
|
||||
- `:CoderAgent` - Open Agent panel
|
||||
- `:CoderAgentToggle` - Toggle Agent panel
|
||||
- `:CoderAgentStop` - Stop running agent
|
||||
|
||||
### Transform
|
||||
- `:CoderTransform` - Transform all tags
|
||||
- `:CoderTransformCursor` - Transform at cursor
|
||||
- `:CoderTransformVisual` - Transform selection
|
||||
|
||||
### Utility
|
||||
- `:CoderIndex` - Open coder companion
|
||||
- `:CoderLogs` - Toggle logs panel
|
||||
- `:CoderType` - Switch Ask/Agent mode
|
||||
- `:CoderTree` - Refresh tree.log
|
||||
- `:CoderTreeView` - View tree.log
|
||||
|
||||
## Configuration Schema
|
||||
|
||||
```lua
|
||||
{
|
||||
llm = {
|
||||
provider = "claude", -- "claude" | "openai" | "gemini" | "copilot" | "ollama"
|
||||
claude = {
|
||||
api_key = nil, -- string, uses ANTHROPIC_API_KEY env if nil
|
||||
model = "claude-sonnet-4-20250514",
|
||||
},
|
||||
openai = {
|
||||
api_key = nil, -- string, uses OPENAI_API_KEY env if nil
|
||||
model = "gpt-4o",
|
||||
endpoint = nil, -- custom endpoint for Azure, OpenRouter, etc.
|
||||
},
|
||||
gemini = {
|
||||
api_key = nil, -- string, uses GEMINI_API_KEY env if nil
|
||||
model = "gemini-2.0-flash",
|
||||
},
|
||||
copilot = {
|
||||
model = "gpt-4o", -- uses OAuth from copilot.lua/copilot.vim
|
||||
},
|
||||
ollama = {
|
||||
host = "http://localhost:11434",
|
||||
model = "deepseek-coder:6.7b",
|
||||
},
|
||||
},
|
||||
window = {
|
||||
width = 25, -- percentage (25 = 25% of screen)
|
||||
position = "left", -- "left" | "right"
|
||||
border = "rounded",
|
||||
},
|
||||
patterns = {
|
||||
open_tag = "/@",
|
||||
close_tag = "@/",
|
||||
file_pattern = "*.coder.*",
|
||||
},
|
||||
auto_gitignore = true,
|
||||
auto_open_ask = true,
|
||||
auto_index = false, -- auto-create coder companion files
|
||||
scheduler = {
|
||||
enabled = true, -- enable event-driven scheduler
|
||||
ollama_scout = true, -- use Ollama as fast scout
|
||||
escalation_threshold = 0.7,
|
||||
max_concurrent = 2,
|
||||
completion_delay_ms = 100,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## LLM Integration
|
||||
|
||||
### Claude API
|
||||
- Endpoint: `https://api.anthropic.com/v1/messages`
|
||||
- Uses `x-api-key` header for authentication
|
||||
- Supports tool use for agent mode
|
||||
|
||||
### OpenAI API
|
||||
- Endpoint: `https://api.openai.com/v1/chat/completions` (configurable)
|
||||
- Uses `Authorization: Bearer` header
|
||||
- Supports tool use for agent mode
|
||||
- Compatible with Azure, OpenRouter, and other OpenAI-compatible APIs
|
||||
|
||||
### Gemini API
|
||||
- Endpoint: `https://generativelanguage.googleapis.com/v1beta/models`
|
||||
- Uses API key in URL parameter
|
||||
- Supports function calling for agent mode
|
||||
|
||||
### Copilot API
|
||||
- Uses GitHub OAuth token from copilot.lua/copilot.vim
|
||||
- Endpoint from token response (typically `api.githubcopilot.com`)
|
||||
- OpenAI-compatible format
|
||||
|
||||
### Ollama API
|
||||
- Endpoint: `{host}/api/generate` or `{host}/api/chat`
|
||||
- No authentication required for local instances
|
||||
- Tool use via prompt-based approach
|
||||
|
||||
## Agent Tool Definitions
|
||||
|
||||
```lua
|
||||
tools = {
|
||||
read_file = { path: string },
|
||||
edit_file = { path: string, find: string, replace: string },
|
||||
write_file = { path: string, content: string },
|
||||
bash = { command: string, timeout?: number },
|
||||
}
|
||||
```
|
||||
|
||||
## Code Injection Strategies
|
||||
|
||||
1. **Refactor**: Replace entire file content
|
||||
2. **Add**: Insert at cursor position in target file
|
||||
3. **Document**: Insert above current function/class
|
||||
4. **Generic**: Prompt user for action
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
@@ -103,62 +316,13 @@ require("codetyper").setup({
|
||||
|
||||
Pattern: `name.coder.extension`
|
||||
|
||||
## Configuration Schema
|
||||
|
||||
```lua
|
||||
{
|
||||
llm = {
|
||||
provider = "claude", -- "claude" | "ollama"
|
||||
claude = {
|
||||
api_key = nil, -- string, uses ANTHROPIC_API_KEY env if nil
|
||||
model = "claude-sonnet-4-20250514",
|
||||
},
|
||||
ollama = {
|
||||
host = "http://localhost:11434",
|
||||
model = "codellama",
|
||||
},
|
||||
},
|
||||
window = {
|
||||
width = 0.4, -- number (percentage if <=1, columns if >1)
|
||||
position = "left", -- "left" | "right"
|
||||
border = "rounded", -- border style for floating windows
|
||||
},
|
||||
patterns = {
|
||||
open_tag = "/@", -- string
|
||||
close_tag = "@/", -- string
|
||||
file_pattern = "*.coder.*",
|
||||
},
|
||||
auto_gitignore = true, -- boolean
|
||||
}
|
||||
```
|
||||
|
||||
## LLM Integration
|
||||
|
||||
### Claude API
|
||||
- Endpoint: `https://api.anthropic.com/v1/messages`
|
||||
- Uses `x-api-key` header for authentication
|
||||
- Requires `anthropic-version: 2023-06-01` header
|
||||
|
||||
### Ollama API
|
||||
- Endpoint: `{host}/api/generate`
|
||||
- No authentication required for local instances
|
||||
- Health check via `/api/tags` endpoint
|
||||
|
||||
## Code Injection Strategies
|
||||
|
||||
1. **Refactor**: Replace entire file content
|
||||
2. **Add**: Insert at cursor position in target file
|
||||
3. **Document**: Insert above current function/class
|
||||
4. **Generic**: Prompt user for action (replace/insert/append/clipboard)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Required**: Neovim >= 0.8.0, curl
|
||||
- **Optional**: telescope.nvim (enhanced file picker)
|
||||
- **Optional**: telescope.nvim (enhanced file picker), copilot.lua or copilot.vim (for Copilot provider)
|
||||
|
||||
## Contact
|
||||
|
||||
- Author: cargdev
|
||||
- Email: carlos.gutierrez@carg.dev
|
||||
- Website: https://cargdev.io
|
||||
- Blog: https://blog.cargdev.io
|
||||
|
||||
328
lua/codetyper/agent/confidence.lua
Normal file
328
lua/codetyper/agent/confidence.lua
Normal 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
|
||||
240
lua/codetyper/agent/diff.lua
Normal file
240
lua/codetyper/agent/diff.lua
Normal 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
|
||||
294
lua/codetyper/agent/executor.lua
Normal file
294
lua/codetyper/agent/executor.lua
Normal 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
|
||||
308
lua/codetyper/agent/init.lua
Normal file
308
lua/codetyper/agent/init.lua
Normal 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
|
||||
312
lua/codetyper/agent/intent.lua
Normal file
312
lua/codetyper/agent/intent.lua
Normal 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
|
||||
246
lua/codetyper/agent/logs.lua
Normal file
246
lua/codetyper/agent/logs.lua
Normal 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
|
||||
117
lua/codetyper/agent/parser.lua
Normal file
117
lua/codetyper/agent/parser.lua
Normal 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
|
||||
478
lua/codetyper/agent/patch.lua
Normal file
478
lua/codetyper/agent/patch.lua
Normal 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
|
||||
438
lua/codetyper/agent/queue.lua
Normal file
438
lua/codetyper/agent/queue.lua
Normal 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
|
||||
488
lua/codetyper/agent/scheduler.lua
Normal file
488
lua/codetyper/agent/scheduler.lua
Normal 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
|
||||
444
lua/codetyper/agent/scope.lua
Normal file
444
lua/codetyper/agent/scope.lua
Normal 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
|
||||
161
lua/codetyper/agent/tools.lua
Normal file
161
lua/codetyper/agent/tools.lua
Normal 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
674
lua/codetyper/agent/ui.lua
Normal 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
|
||||
419
lua/codetyper/agent/worker.lua
Normal file
419
lua/codetyper/agent/worker.lua
Normal 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
@@ -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
|
||||
|
||||
44
lua/codetyper/chat_switcher.lua
Normal file
44
lua/codetyper/chat_switcher.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
501
lua/codetyper/llm/copilot.lua
Normal file
501
lua/codetyper/llm/copilot.lua
Normal 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
|
||||
394
lua/codetyper/llm/gemini.lua
Normal file
394
lua/codetyper/llm/gemini.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
345
lua/codetyper/llm/openai.lua
Normal file
345
lua/codetyper/llm/openai.lua
Normal 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
|
||||
205
lua/codetyper/logs_panel.lua
Normal file
205
lua/codetyper/logs_panel.lua
Normal 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
|
||||
81
lua/codetyper/prompts/agent.lua
Normal file
81
lua/codetyper/prompts/agent.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
75
lua/codetyper/utils/langmap.lua
Normal file
75
lua/codetyper/utils/langmap.lua
Normal 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
|
||||
@@ -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
48
tests/minimal_init.lua
Normal 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
62
tests/run_tests.sh
Executable 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}"
|
||||
148
tests/spec/confidence_spec.lua
Normal file
148
tests/spec/confidence_spec.lua
Normal 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
149
tests/spec/config_spec.lua
Normal 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
286
tests/spec/intent_spec.lua
Normal 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
118
tests/spec/llm_spec.lua
Normal 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
280
tests/spec/logs_spec.lua
Normal 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
141
tests/spec/parser_spec.lua
Normal 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
305
tests/spec/patch_spec.lua
Normal 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
332
tests/spec/queue_spec.lua
Normal 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
139
tests/spec/utils_spec.lua
Normal 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)
|
||||
Reference in New Issue
Block a user