diff --git a/config/nvim/lua/plugins.lua b/config/nvim/lua/plugins.lua index 1bb22b9..932aa4a 100644 --- a/config/nvim/lua/plugins.lua +++ b/config/nvim/lua/plugins.lua @@ -146,19 +146,50 @@ return { end, }, + -- llama.vim (disabled in favor of sweep.nvim) + -- { + -- 'ggml-org/llama.vim', + -- init = function() + -- local url = os.getenv 'LLAMA_SERVER_URL' or 'http://localhost:8012' + -- vim.g.llama_config = { + -- endpoint = url .. '/infill', + -- show_info = 1, + -- keymap_trigger = '', + -- keymap_accept_full = '', + -- keymap_accept_line = '', + -- keymap_accept_word = '', + -- } + -- vim.api.nvim_set_hl(0, 'llama_hl_hint', { fg = '#f2cdcd', ctermfg = 209 }) + -- end, + -- }, + + -- sweep.nvim - AI autocomplete using Sweep's next-edit model { - 'ggml-org/llama.vim', - init = function() - local url = os.getenv 'LLAMA_SERVER_URL' or 'http://localhost:8012' - vim.g.llama_config = { - endpoint = url .. '/infill', - show_info = 1, - keymap_trigger = '', - keymap_accept_full = '', - keymap_accept_line = '', - keymap_accept_word = '', + dir = vim.fn.expand '~/dotfiles/sweep.nvim', + dependencies = { 'nvim-lua/plenary.nvim' }, + config = function() + require('sweep').setup { + debounce_ms = 100, + keymaps = { + trigger = '', + accept_full = '', + accept_line = '', + accept_word = '', + dismiss = '', + }, + server = { + endpoint = os.getenv 'LLAMA_SERVER_URL' or 'http://localhost:8012', + timeout = 5000, + n_predict = 128, + temperature = 0.1, + cache_prompt = true, + }, + ui = { + hl_group = 'SweepGhostText', + }, } - vim.api.nvim_set_hl(0, 'llama_hl_hint', { fg = '#f2cdcd', ctermfg = 209 }) + -- Match llama.vim highlight style + vim.api.nvim_set_hl(0, 'SweepGhostText', { fg = '#f2cdcd', ctermfg = 209 }) end, }, diff --git a/sweep-ai-autocomplete-analysis.md b/sweep-ai-autocomplete-analysis.md new file mode 100644 index 0000000..48328cf --- /dev/null +++ b/sweep-ai-autocomplete-analysis.md @@ -0,0 +1,573 @@ +# Sweep AI Autocomplete Plugin - Deep Technical Analysis + +## Executive Summary + +This document provides a comprehensive analysis of the Sweep AI Autocomplete plugin for JetBrains IDEs. Due to network restrictions preventing direct download and decompilation, this analysis is based on extensive research of Sweep's official blog posts, documentation, Hugging Face model cards, and community discussions. + +--- + +## 1. Architecture Overview + +### 1.1 High-Level Architecture + +Sweep AI uses a **custom inference engine** rather than calling external APIs like OpenAI/Anthropic: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ JetBrains IDE │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────┐ │ +│ │ PSI (Program │ │ Context │ │ Inline │ │ +│ │ Structure │──▶│ Builder │──▶│ Completion │ │ +│ │ Interface) │ │ │ │ Provider │ │ +│ └─────────────────┘ └────────┬────────┘ └──────────────┘ │ +└──────────────────────────────────┼──────────────────────────────┘ + │ + ┌──────────────▼──────────────┐ + │ Sweep Inference Engine │ + │ (Custom datacenter GPUs) │ + │ - Speculative Decoding │ + │ - KV Cache Optimization │ + │ - Regional Proximity │ + └─────────────────────────────┘ +``` + +### 1.2 Key Architectural Decisions + +| Decision | Rationale | +|----------|-----------| +| **Custom inference engine** | Control over latency, speculative decoding, GPU provisioning | +| **JetBrains-exclusive** | Leverage PSI for instant definition lookup | +| **Regional datacenters** | Reduce network latency (143ms → 32ms for west coast) | +| **1.5B parameter model** | Fast local inference, beats 7B models on benchmarks | + +### 1.3 Latency Budget + +Sweep operates within a **100ms total latency budget**: + +| Component | Time | +|-----------|------| +| PSI lookup (cold cache) | ~30ms | +| PSI lookup (warm cache) | <1ms | +| Network latency (regional) | ~30ms | +| TTFT (warm KV cache) | ~10ms | +| Decoding | ~50ms | +| UI rendering | ~10ms | + +--- + +## 2. Context Building (CRITICAL) + +### 2.1 The PSI Advantage + +Sweep leverages JetBrains' **Program Structure Interface (PSI)** - an in-process semantic code model: + +``` +Traditional Approach (VSCode + LSP): +┌─────────┐ IPC/Network ┌─────────────┐ +│ Editor │ ◀───────────────▶ │ LSP Server │ +└─────────┘ └─────────────┘ + +JetBrains PSI Approach: +┌───────────────────────────────────────────┐ +│ IDE Process │ +│ ┌─────────┐ In-Memory ┌─────────┐ │ +│ │ Editor │ ◀─────────────▶ │ PSI │ │ +│ └─────────┘ └─────────┘ │ +└───────────────────────────────────────────┘ +``` + +**Why PSI matters:** +- Instant type resolution without network calls +- Maintains perfect representation of codebase in memory +- Updated incrementally as you type +- Works on any codebase/language the IDE has indexed + +### 2.2 Definition Lookup Strategy + +When the cursor is at `client.query(`: + +```python +# PROBLEM: Searching for "client" returns irrelevant results +results = search("client") # Returns hundreds of occurrences + +# SOLUTION: PSI resolves the actual type +client_type = psi.resolve_type("client") # → DatabaseClient +definition = psi.get_definition(client_type) # → Exact class definition +``` + +**The key insight:** PSI distinguishes between where code is *used* vs where it's *defined*. + +### 2.3 Context Window Composition + +Based on research, Sweep's context likely includes: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CONTEXT WINDOW (~4K-8K tokens) │ +├─────────────────────────────────────────────────────────────┤ +│ 1. DEFINITIONS (from PSI) │ +│ - Type definitions around cursor │ +│ - Method signatures being called │ +│ - Import statements │ +├─────────────────────────────────────────────────────────────┤ +│ 2. RECENT EDITS (for next-edit prediction) │ +│ - Diffs from current editing session │ +│ - Recently modified functions │ +│ - Commit context (what else changed) │ +├─────────────────────────────────────────────────────────────┤ +│ 3. CURRENT FILE CONTEXT │ +│ - Code before cursor (prefix) │ +│ - Code after cursor (suffix) │ +│ - Surrounding function/class context │ +├─────────────────────────────────────────────────────────────┤ +│ 4. RELATED FILES │ +│ - Other files touched in same commit │ +│ - Files with related definitions │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.4 Why NOT to Use Search-Based Context + +Sweep explicitly states problems with traditional approaches: + +| Approach | Problem | +|----------|---------| +| **Vector search** | Too slow for real-time (>100ms), no semantic understanding | +| **TF-IDF** | Returns dozens of irrelevant files, can't distinguish usage vs definition | +| **Embedding search** | Requires indexing, latency spikes, buries actual definitions | + +--- + +## 3. Prompt Format + +### 3.1 Traditional FIM Format (Starting Point) + +Sweep started with standard Fill-in-the-Middle: + +``` +<|prefix|>def get_car_metadata(car: Car) -> str: + return f"{<|suffix|>} {car.model} {car.year}"<|middle|> +``` + +**Output:** `car.make` + +### 3.2 Next-Edit Prompt Format + +For next-edit autocomplete, the format evolved to include recent diffs: + +``` +[FILE CONTEXT] +# Recent definitions resolved via PSI +class DatabaseClient: + def query(self, sql: str) -> QueryResult: + ... + +[RECENT DIFFS] +# Changes from current editing session +--- a/utils.py ++++ b/utils.py +@@ -10,6 +10,7 @@ + def process_data(data): ++ max_depth = 10 # Added this parameter + +[CURRENT STATE] +# Code around cursor with cursor position marked +def search_recursive(node, value): + if node is None: + return None + self.search_recursive(node.left, value,█) + +[PREDICTED EDIT] +# Model outputs rewritten code +``` + +### 3.3 Diff Format Discovery + +Sweep tested **30+ diff formats** using genetic algorithms: + +| Format | Performance | +|--------|-------------| +| Unified diff (`---/+++`) | Lower | +| **Simple original/updated blocks** | **Winner** | +| JSON patch | Lower | +| Line-by-line | Lower | + +**Winning format:** +``` + +self.search_recursive(node.left, value,) + + +self.search_recursive(node.left, value, max_depth - 1 if max_depth is not None else None) + +``` + +--- + +## 4. Model Architecture + +### 4.1 Model Specifications + +| Attribute | Value | +|-----------|-------| +| **Model name** | sweep-next-edit-1.5B | +| **Parameters** | 1.5 billion | +| **Format** | GGUF Q8_0 | +| **Size** | 1.54 GB | +| **License** | Apache 2.0 (open weights) | +| **Base model** | Likely Qwen2.5-Coder | + +### 4.2 Training Approach + +``` +Training Pipeline: +┌───────────────────────────────────────────────────────────┐ +│ 1. DATA COLLECTION │ +│ - ~80K FIM examples from 400 OSS repos │ +│ - Repos created in past 6 months (avoid contamination)│ +│ - Permissively licensed │ +└───────────────────────┬───────────────────────────────────┘ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ 2. AST-DIFF SAMPLING │ +│ - Diff AST trees before/after commit │ +│ - Only sample from CHANGED AST nodes │ +│ - Upsamples frequently edited code patterns │ +└───────────────────────┬───────────────────────────────────┘ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ 3. SYNTAX-AWARE FIM (SAFIM) │ +│ - All completions are valid AST nodes │ +│ - No random substrings breaking syntax │ +└───────────────────────┬───────────────────────────────────┘ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ 4. SUPERVISED FINE-TUNING │ +│ - Full-parameter SFT via TRL │ +│ - 8x H200 GPUs on Modal │ +│ - 4 hours training time │ +└───────────────────────┬───────────────────────────────────┘ + ▼ +┌───────────────────────────────────────────────────────────┐ +│ 5. REINFORCEMENT LEARNING │ +│ - Tree-sitter parse validation as reward │ +│ - Ensures syntactically valid outputs │ +└───────────────────────────────────────────────────────────┘ +``` + +### 4.3 Why AST-Diff Sampling Matters + +Standard FIM treats code as text, leading to: +- Completions that break at AST boundaries +- Random sampling of rarely-edited patterns + +**AST-diff sampling ensures:** +- Focus on code patterns developers actually modify +- Completions respect syntactic structure +- Higher acceptance rate in practice + +--- + +## 5. Inference Optimizations + +### 5.1 Speculative Decoding + +For next-edit autocomplete, **>90% of tokens are unchanged** when rewriting around cursor: + +``` +Input: self.search_recursive(node.left, value,) +Output: self.search_recursive(node.left, value, max_depth - 1) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^ + (identical - can be speculated) (new tokens) +``` + +**Performance gains:** +- 10x improvement on decoding time +- 5x improvement on total time + +### 5.2 KV Cache Optimization + +``` +Cold Request: +┌─────────┐ ┌─────────────┐ ┌─────────────┐ +│ Request │───▶│ Build Cache │───▶│ Generate │ ~30ms + generation +└─────────┘ └─────────────┘ └─────────────┘ + +Warm Request (same file context): +┌─────────┐ ┌─────────────┐ +│ Request │───▶│ Generate │ <1ms + generation +└─────────┘ │ (reuse KV) │ + └─────────────┘ +``` + +### 5.3 Early Termination + +Sweep implements a trick for faster perceived latency: + +```python +# Pseudocode for early termination +def generate_completion(prompt): + tokens = [] + for token in stream_tokens(prompt): + tokens.append(token) + if has_enough_changes(tokens): + # Return early, cancel rest of stream + return format_suggestion(tokens) + return format_suggestion(tokens) +``` + +--- + +## 6. Completion Triggers & Display + +### 6.1 Trigger Strategy + +Based on research, Sweep likely triggers completions: + +| Trigger | Description | +|---------|-------------| +| **On keystroke** | Response times faster than typing speed enable per-keystroke suggestions | +| **On pause** | Traditional debounce for expensive operations | +| **After specific tokens** | `.`, `(`, `,`, etc. | +| **After edits** | When code structure changes (next-edit prediction) | + +### 6.2 Debouncing + +> "There is currently no ready-made solution for debounce/throttle in the IntelliJ platform, so plugin developers have to take care of caching/throttling/timeouts in their completion contributor by themselves." + +Sweep handles this internally with their <100ms latency enabling near-instant responses. + +### 6.3 Display Strategy + +**Single-line first:** +> "To simplify the process of reviewing suggestions, multiline code suggestions are now displayed only after accepting a single-line suggestion, allowing you to review and accept code gradually." + +**Next-edit jumping:** +- Tab key accepts suggestion AND jumps to next predicted edit location +- Enables "flow state" for repetitive changes + +### 6.4 Diff Visualization + +For next-edit suggestions that modify existing code: + +``` +# Visual diff in editor +- self.search_recursive(node.left, value,) ++ self.search_recursive(node.left, value, max_depth - 1) +``` + +--- + +## 7. Caching Strategy + +### 7.1 PSI Cache + +| State | Lookup Time | +|-------|-------------| +| Cold (first access) | ~30ms | +| Warm (subsequent) | <1ms | + +### 7.2 Model Cache + +- **KV cache** for repeated contexts +- **Regional caching** in datacenters +- **Client-side caching** of recent suggestions + +--- + +## 8. Key Differentiators from Competitors + +### 8.1 vs GitHub Copilot + +| Feature | Sweep | Copilot | +|---------|-------|---------| +| Model | Custom 1.5B | GPT-4o-mini via API | +| Latency control | Full | Limited | +| Context | PSI (rich semantic) | Limited to visible context | +| Next-edit prediction | Yes | Limited | +| Local inference | Yes (optional) | No | +| Cost | Free | $10-19/month | + +### 8.2 vs Cursor + +| Feature | Sweep | Cursor | +|---------|-------|--------| +| IDE | JetBrains only | VSCode fork | +| Context | PSI (instant) | LSP (IPC overhead) | +| Edit speed | <1 second for 500-line files | Slower, more agentic | +| Tab jumping | Yes | Yes | + +--- + +## 9. Implementation Guide for Neovim + +### 9.1 Key Components to Build + +```lua +-- Neovim equivalent architecture +local sweep_nvim = { + -- 1. Context Builder (replaces PSI) + context = { + get_definitions = function() + -- Use LSP for type resolution + -- vim.lsp.buf.definition() + -- vim.lsp.buf.type_definition() + end, + get_recent_edits = function() + -- Track buffer changes + -- Use vim.api.nvim_buf_attach() for change events + end, + get_cursor_context = function() + -- Prefix/suffix around cursor + end, + }, + + -- 2. Model Interface + model = { + -- Local: llama.cpp, ollama, LM Studio + -- Remote: Custom API + }, + + -- 3. Completion Provider + completion = { + trigger = function() end, + display = function() end, + accept = function() end, + }, +} +``` + +### 9.2 Context Building for Neovim + +```lua +-- Replicate PSI behavior with LSP +local function get_definitions_at_cursor() + local params = vim.lsp.util.make_position_params() + + -- Get type definition + local type_def = vim.lsp.buf_request_sync( + 0, 'textDocument/typeDefinition', params, 1000 + ) + + -- Get hover info for signatures + local hover = vim.lsp.buf_request_sync( + 0, 'textDocument/hover', params, 1000 + ) + + -- Get document symbols for context + local symbols = vim.lsp.buf_request_sync( + 0, 'textDocument/documentSymbol', { + textDocument = vim.lsp.util.make_text_document_params() + }, 1000 + ) + + return { + type_definition = type_def, + hover = hover, + symbols = symbols, + } +end +``` + +### 9.3 Tracking Recent Edits + +```lua +-- Track edits for next-edit prediction +local edit_history = {} + +vim.api.nvim_buf_attach(0, false, { + on_lines = function(_, buf, _, first, last_old, last_new, _) + table.insert(edit_history, { + buffer = buf, + range = {first, last_old, last_new}, + timestamp = vim.loop.now(), + file = vim.api.nvim_buf_get_name(buf), + }) + -- Keep only recent edits + while #edit_history > 20 do + table.remove(edit_history, 1) + end + end, +}) +``` + +### 9.4 Prompt Template + +```lua +local function build_prompt(cursor_pos) + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local row, col = cursor_pos[1], cursor_pos[2] + + -- Split into prefix and suffix + local prefix_lines = vim.list_slice(lines, 1, row) + prefix_lines[#prefix_lines] = string.sub(prefix_lines[#prefix_lines], 1, col) + + local suffix_lines = vim.list_slice(lines, row, #lines) + suffix_lines[1] = string.sub(suffix_lines[1], col + 1) + + local prefix = table.concat(prefix_lines, "\n") + local suffix = table.concat(suffix_lines, "\n") + + -- Get definitions via LSP + local definitions = get_definitions_at_cursor() + + -- Get recent edits + local recent_diffs = format_recent_edits(edit_history) + + return string.format([[ +[DEFINITIONS] +%s + +[RECENT EDITS] +%s + +[CURRENT FILE] +<|prefix|>%s<|suffix|>%s<|middle|> +]], definitions, recent_diffs, prefix, suffix) +end +``` + +### 9.5 Model Options + +| Option | Latency | Quality | Setup | +|--------|---------|---------|-------| +| **sweep-next-edit-1.5B via Ollama** | ~200ms | Good | Easy | +| **sweep-next-edit-1.5B via llama.cpp** | ~100ms | Good | Medium | +| **Qwen2.5-Coder-1.5B** | ~150ms | Good | Easy | +| **DeepSeek-Coder-1.3B** | ~150ms | Good | Easy | +| **Remote API (Claude/GPT)** | ~500ms+ | Best | Easy | + +--- + +## 10. Sources & References + +1. [Sweep AI Blog - Autocomplete Context](https://blog.sweep.dev/posts/autocomplete-context) +2. [Sweep AI Blog - Next-Edit JetBrains](https://blog.sweep.dev/posts/next-edit-jetbrains) +3. [Sweep Documentation](https://docs.sweep.dev/) +4. [Sweep Next-Edit 1.5B Model (Hugging Face)](https://huggingface.co/sweepai/sweep-next-edit-1.5B) +5. [JetBrains PSI Documentation](https://plugins.jetbrains.com/docs/intellij/psi.html) +6. [Hacker News Discussion - Open-weights Model](https://news.ycombinator.com/item?id=46713106) +7. [Hacker News Discussion - JetBrains Plugin](https://news.ycombinator.com/item?id=45505487) +8. [JetBrains AI Completion Blog](https://blog.jetbrains.com/ai/2024/10/complete-the-un-completable-the-state-of-ai-completion-in-jetbrains-ides/) +9. [Sweep GitHub Organization](https://github.com/sweepai) +10. [ByteIota Analysis](https://byteiota.com/sweep-ai-1-5b-model-beats-github-copilot-at-code-autocomplete/) + +--- + +## 11. Appendix: Neovim Plugins for Reference + +### Existing Next-Edit Implementations + +1. **[nes.nvim](https://github.com/Xuyuanp/nes.nvim)** - Next edit suggestion using Copilot +2. **[cursortab.nvim](https://github.com/reachingforthejack/cursortab.nvim)** - Reverse-engineered Cursor Tab API +3. **[sidekick.nvim](https://github.com/folke/sidekick.nvim)** - Copilot LSP "Next Edit Suggestions" integration +4. **[avante.nvim](https://github.com/yetone/avante.nvim)** - Cursor-like AI assistant + +### Key Takeaways for Neovim Implementation + +1. **LSP is your PSI** - Use LSP for definition lookup, but be aware of IPC latency +2. **Track edits** - Maintain edit history for next-edit prediction context +3. **Use local models** - sweep-next-edit-1.5B is designed for local inference +4. **Simple diff format** - Original/updated blocks outperform unified diffs +5. **Speculative decoding** - Enable in llama.cpp/ollama for 5-10x speedup +6. **Debounce wisely** - ~100ms debounce, or per-keystroke if inference is fast enough diff --git a/sweep.nvim/README.md b/sweep.nvim/README.md new file mode 100644 index 0000000..dfbc056 --- /dev/null +++ b/sweep.nvim/README.md @@ -0,0 +1,141 @@ +# sweep.nvim + +AI autocomplete plugin for Neovim using Sweep's 1.5B next-edit model, powered by llama.cpp. + +## Requirements + +- Neovim 0.10+ +- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) +- llama.cpp server running with the Sweep model + +## Installation + +Using [lazy.nvim](https://github.com/folke/lazy.nvim): + +```lua +{ + 'path/to/sweep.nvim', + dependencies = { 'nvim-lua/plenary.nvim' }, + config = function() + require('sweep').setup({ + -- options + }) + end, +} +``` + +## Configuration + +Default configuration with explanations: + +```lua +require('sweep').setup({ + auto_enable = true, -- Enable on setup + debounce_ms = 100, -- Delay before triggering completion + show_info = true, -- Show token/latency info + + keymaps = { + trigger = '', -- Manually trigger completion + accept_full = '', -- Accept full completion + accept_line = '', -- Accept first line only + accept_word = '',-- Accept first word only + dismiss = '', -- Dismiss completion + }, + + context = { + prefix_lines = 100, -- Lines before cursor for context + suffix_lines = 50, -- Lines after cursor for context + max_ring_chunks = 16, -- Max chunks in ring buffer + chunk_size = 64, -- Lines per chunk + use_lsp = true, -- Include LSP context (definitions, etc.) + use_treesitter = true, -- Include treesitter scope context + }, + + server = { + endpoint = 'http://localhost:8080', -- Or set LLAMA_SERVER_URL env var + timeout = 5000, -- Request timeout (ms) + n_predict = 128, -- Max tokens to generate + temperature = 0.1, -- Sampling temperature + cache_prompt = true, -- Enable llama.cpp prompt caching + }, + + ui = { + show_info = true, -- Show completion metadata + hl_group = 'SweepGhostText', -- Ghost text highlight (links to Comment) + info_hl_group = 'SweepInfo', -- Info text highlight (links to DiagnosticInfo) + }, + + filetypes_exclude = { + 'help', + 'TelescopePrompt', + 'lazy', + 'mason', + 'neo-tree', + 'NvimTree', + 'toggleterm', + }, +}) +``` + +## Commands + +| Command | Description | +|-----------------|--------------------------------| +| `:SweepEnable` | Enable autocomplete | +| `:SweepDisable` | Disable autocomplete | +| `:SweepToggle` | Toggle autocomplete on/off | +| `:SweepStatus` | Show current status | +| `:SweepDebug` | Open debug pane with diagnostics | + +## Keymaps + +Default keymaps (active in insert mode when completion is visible): + +| Key | Action | +|---------------|--------------------------------| +| `` | Manually trigger completion | +| `` | Accept full completion | +| `` | Accept first line | +| `` | Accept first word | +| `` | Dismiss completion | + +To customize, override in the `keymaps` config table. Set a keymap to `false` to disable it. + +## llama.cpp Setup + +Run the llama.cpp server with the Sweep model: + +```bash +# Download the model (Q4_K_M quantization recommended) +# Then start the server: +llama-server -m sweep-next-edit-1.5B.Q4_K_M.gguf --port 8080 + +# Or specify a different port and set the env var: +export LLAMA_SERVER_URL=http://localhost:8888 +llama-server -m sweep-next-edit-1.5B.Q4_K_M.gguf --port 8888 +``` + +## Architecture + +Module overview for contributors: + +| Module | Purpose | +|--------------|----------------------------------------------| +| `init` | Main entry point, enable/disable logic | +| `config` | Configuration management and defaults | +| `completion` | Core completion orchestration | +| `context` | Gathers surrounding code context | +| `ring` | Ring buffer for recently edited code chunks | +| `cache` | Caches completions to reduce server requests | +| `fim` | Fill-in-the-middle prompt formatting | +| `parser` | Parses llama.cpp streaming responses | +| `http` | HTTP client for server communication | +| `ui` | Ghost text rendering and info display | +| `keymaps` | Keymap setup and handlers | +| `autocmds` | Autocommand management | +| `edits` | Applies accepted completions to buffer | +| `debug` | Debug pane and diagnostics | + +## License + +MIT diff --git a/sweep.nvim/lua/sweep/autocmds.lua b/sweep.nvim/lua/sweep/autocmds.lua new file mode 100644 index 0000000..6522200 --- /dev/null +++ b/sweep.nvim/lua/sweep/autocmds.lua @@ -0,0 +1,221 @@ +-- sweep.nvim - Autocmds module for completion triggers and ring buffer population +-- Sets up autocommands for triggering completions and collecting context + +local M = {} + +-- Augroup name +local AUGROUP_NAME = 'sweep' + +-- Track if autocmds are set up +local is_setup = false + +--- Get the config module +---@return table +local function get_config() + return require('sweep.config').get() +end + +--- Check if the current filetype is excluded from completion +---@param bufnr number Buffer number +---@return boolean +local function is_filetype_excluded(bufnr) + local config = get_config() + local filetype = vim.bo[bufnr].filetype + + for _, excluded in ipairs(config.filetypes_exclude or {}) do + if filetype == excluded then + return true + end + end + + return false +end + +--- Add visible portion of buffer to ring buffer +---@param source string Source identifier (e.g., 'buffer_enter', 'buffer_leave', 'save') +local function add_visible_to_ring(source) + local ring = require('sweep.ring') + local bufnr = vim.api.nvim_get_current_buf() + local filename = vim.api.nvim_buf_get_name(bufnr) + local filetype = vim.bo[bufnr].filetype + + -- Get visible line range + local first = vim.fn.line('w0') + local last = vim.fn.line('w$') + + -- Get lines (nvim_buf_get_lines is 0-indexed) + local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, first - 1, last, false) + if not ok or #lines == 0 then + return + end + + ring.add({ + content = table.concat(lines, '\n'), + filename = filename, + filetype = filetype, + source = source, + }) +end + +--- Handle CursorMovedI event - trigger completion +---@param bufnr number +local function on_cursor_moved_i(bufnr) + if is_filetype_excluded(bufnr) then + return + end + + local completion = require('sweep.completion') + completion.trigger() +end + +--- Handle InsertLeave event - cancel and clear +local function on_insert_leave() + local completion = require('sweep.completion') + local ui = require('sweep.ui') + + completion.cancel() + ui.clear() +end + +--- Handle BufLeave event - cancel completion and add to ring +---@param bufnr number +local function on_buf_leave(bufnr) + local completion = require('sweep.completion') + local ui = require('sweep.ui') + + -- Cancel any pending completion + completion.cancel() + ui.clear() + + -- Add visible content to ring buffer + add_visible_to_ring('buffer_leave') +end + +--- Handle BufEnter event - add visible content to ring and attach edit tracking +local function on_buf_enter() + local bufnr = vim.api.nvim_get_current_buf() + + -- Skip special buffers (terminals, quickfix, etc.) + if vim.bo[bufnr].buftype ~= '' then + return + end + + -- Skip excluded filetypes + if is_filetype_excluded(bufnr) then + return + end + + -- Add visible content to ring buffer + add_visible_to_ring('buffer_enter') + + -- Attach edit tracking for next-edit prediction + local edits = require('sweep.edits') + edits.attach(bufnr) +end + +--- Handle BufWritePost event - add visible content to ring +local function on_buf_write_post() + add_visible_to_ring('save') +end + +--- Handle TextYankPost event - add yanked text to ring +local function on_text_yank_post() + local event = vim.v.event + if not event or not event.regcontents then + return + end + + local ring = require('sweep.ring') + local bufnr = vim.api.nvim_get_current_buf() + local filename = vim.api.nvim_buf_get_name(bufnr) + local filetype = vim.bo[bufnr].filetype + + local content = table.concat(event.regcontents, '\n') + if content == '' then + return + end + + ring.add({ + content = content, + filename = filename, + filetype = filetype, + source = 'yank', + }) +end + +--- Set up all autocmds for sweep +function M.setup() + -- Create augroup with clear = true to make setup idempotent + local group = vim.api.nvim_create_augroup(AUGROUP_NAME, { clear = true }) + + -- CursorMovedI - trigger completion (debounced by completion module) + vim.api.nvim_create_autocmd('CursorMovedI', { + group = group, + callback = function(args) + on_cursor_moved_i(args.buf) + end, + desc = 'Sweep: trigger completion on cursor move in insert mode', + }) + + -- InsertLeave - cancel pending completion and clear UI + vim.api.nvim_create_autocmd('InsertLeave', { + group = group, + callback = function() + on_insert_leave() + end, + desc = 'Sweep: cancel completion on insert leave', + }) + + -- BufLeave - cancel pending completion, clear UI, and add to ring + vim.api.nvim_create_autocmd('BufLeave', { + group = group, + callback = function(args) + on_buf_leave(args.buf) + end, + desc = 'Sweep: handle buffer leave', + }) + + -- BufEnter - add visible portion to ring buffer + vim.api.nvim_create_autocmd('BufEnter', { + group = group, + callback = function() + on_buf_enter() + end, + desc = 'Sweep: add visible content to ring on buffer enter', + }) + + -- BufWritePost - add visible portion to ring buffer + vim.api.nvim_create_autocmd('BufWritePost', { + group = group, + callback = function() + on_buf_write_post() + end, + desc = 'Sweep: add visible content to ring on save', + }) + + -- TextYankPost - add yanked text to ring buffer + vim.api.nvim_create_autocmd('TextYankPost', { + group = group, + callback = function() + on_text_yank_post() + end, + desc = 'Sweep: add yanked text to ring buffer', + }) + + is_setup = true +end + +--- Remove all autocmds for sweep +function M.teardown() + -- Clear the augroup (removes all autocmds in it) + pcall(vim.api.nvim_create_augroup, AUGROUP_NAME, { clear = true }) + is_setup = false +end + +--- Check if autocmds are set up +---@return boolean +function M.is_setup() + return is_setup +end + +return M diff --git a/sweep.nvim/lua/sweep/cache.lua b/sweep.nvim/lua/sweep/cache.lua new file mode 100644 index 0000000..48dfbc5 --- /dev/null +++ b/sweep.nvim/lua/sweep/cache.lua @@ -0,0 +1,238 @@ +-- LRU cache for completion results +-- Inspired by llama.vim's caching approach + +local M = {} + +-- Default configuration +local defaults = { + max_entries = 100, + ttl_ms = 60000, -- 1 minute default TTL, 0 = no expiry +} + +-- Internal state +local config = {} +local entries = {} -- key -> { value, created_at, accessed_at } +local access_order = {} -- ordered list of keys by access time (oldest first) +local entry_count = 0 -- track count for O(1) lookup +local stats = { + hits = 0, + misses = 0, + evictions = 0, +} + +-- Key generation settings +local PREFIX_LENGTH = 100 +local SUFFIX_LENGTH = 50 + +--- Get current time in milliseconds +---@return number +local function now_ms() + local sec, usec = vim.loop.gettimeofday() + return sec * 1000 + math.floor(usec / 1000) +end + +--- Check if an entry has expired +---@param entry table +---@return boolean +local function is_expired(entry) + if config.ttl_ms == 0 then + return false + end + return (now_ms() - entry.created_at) > config.ttl_ms +end + +--- Find and remove a key from access_order list +---@param key string +local function remove_from_access_order(key) + for i, k in ipairs(access_order) do + if k == key then + table.remove(access_order, i) + return + end + end +end + +--- Add key to end of access_order (most recently used) +---@param key string +local function touch(key) + remove_from_access_order(key) + table.insert(access_order, key) +end + +--- Evict least recently used entry +local function evict_lru() + if #access_order == 0 then + return + end + + -- First, try to evict expired entries + for i = 1, #access_order do + local key = access_order[i] + local entry = entries[key] + if entry and is_expired(entry) then + table.remove(access_order, i) + entries[key] = nil + entry_count = entry_count - 1 + stats.evictions = stats.evictions + 1 + return + end + end + + -- Otherwise, evict the least recently used (first in list) + local lru_key = table.remove(access_order, 1) + if lru_key and entries[lru_key] then + entries[lru_key] = nil + entry_count = entry_count - 1 + stats.evictions = stats.evictions + 1 + end +end + +--- Initialize the cache with options +---@param opts? table +function M.setup(opts) + opts = opts or {} + config = vim.tbl_deep_extend('force', defaults, opts) + + -- Clear cache on setup + entries = {} + access_order = {} + entry_count = 0 + stats = { + hits = 0, + misses = 0, + evictions = 0, + } +end + +--- Store a value in the cache +---@param key string +---@param value any +function M.set(key, value) + if value == nil then + M.remove(key) + return + end + + local existing = entries[key] + local current_time = now_ms() + + if existing then + -- Update existing entry + existing.value = value + existing.created_at = current_time + existing.accessed_at = current_time + touch(key) + else + -- Check if we need to evict + while entry_count >= config.max_entries do + evict_lru() + end + + -- Add new entry + entries[key] = { + value = value, + created_at = current_time, + accessed_at = current_time, + } + table.insert(access_order, key) + entry_count = entry_count + 1 + end +end + +--- Get a value from the cache +---@param key string +---@return any|nil +function M.get(key) + local entry = entries[key] + + if not entry then + stats.misses = stats.misses + 1 + return nil + end + + if is_expired(entry) then + -- Remove expired entry + M.remove(key) + stats.misses = stats.misses + 1 + return nil + end + + -- Update access time and order + entry.accessed_at = now_ms() + touch(key) + stats.hits = stats.hits + 1 + + return entry.value +end + +--- Check if a key exists and is not expired (without updating recency) +---@param key string +---@return boolean +function M.has(key) + local entry = entries[key] + + if not entry then + return false + end + + if is_expired(entry) then + return false + end + + return true +end + +--- Remove a specific entry +---@param key string +function M.remove(key) + if entries[key] then + entries[key] = nil + remove_from_access_order(key) + entry_count = entry_count - 1 + end +end + +--- Clear all entries +function M.clear() + entries = {} + access_order = {} + entry_count = 0 + -- Note: stats are preserved +end + +--- Get cache statistics +---@return table +function M.stats() + return { + entries = entry_count, + hits = stats.hits, + misses = stats.misses, + evictions = stats.evictions, + } +end + +--- Generate a cache key from completion context +---@param opts table { prefix: string, suffix: string, filename: string? } +---@return string +function M.make_key(opts) + local prefix = opts.prefix or '' + local suffix = opts.suffix or '' + local filename = opts.filename or '' + + -- Truncate prefix to last N characters + local prefix_part = prefix + if #prefix > PREFIX_LENGTH then + prefix_part = prefix:sub(-PREFIX_LENGTH) + end + + -- Truncate suffix to first N characters + local suffix_part = suffix + if #suffix > SUFFIX_LENGTH then + suffix_part = suffix:sub(1, SUFFIX_LENGTH) + end + + -- Format: filename|prefix|suffix + return string.format('%s|%s|%s', filename, prefix_part, suffix_part) +end + +return M diff --git a/sweep.nvim/lua/sweep/completion.lua b/sweep.nvim/lua/sweep/completion.lua new file mode 100644 index 0000000..2c1d475 --- /dev/null +++ b/sweep.nvim/lua/sweep/completion.lua @@ -0,0 +1,597 @@ +-- sweep.nvim - Completion trigger and debouncing orchestration module +-- Ties together FIM, HTTP, parser, UI, cache, context, and ring buffer modules + +local M = {} + +-- Module dependencies (lazy loaded) +local config +local http +local fim +local parser +local ui +local cache +local context +local ring +local edits + +-- Internal state +local state = { + debounce_timer = nil, -- Current debounce timer + current_handle = nil, -- Current HTTP request handle + enabled = true, -- Whether completion is enabled + request_start_time = nil, -- For latency tracking + last_latency_ms = nil, -- Last request latency + pending = false, -- Whether a request is currently pending + request_id = 0, -- Monotonic counter to detect stale async callbacks +} + +--- Load dependencies lazily +local function load_deps() + if not config then + config = require('sweep.config') + http = require('sweep.http') + fim = require('sweep.fim') + parser = require('sweep.parser') + ui = require('sweep.ui') + cache = require('sweep.cache') + context = require('sweep.context') + ring = require('sweep.ring') + edits = require('sweep.edits') + end +end + +--- Check if completion should be triggered for current buffer +---@return boolean +local function should_trigger() + load_deps() + + local bufnr = vim.api.nvim_get_current_buf() + local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) + local cfg = config.get() + + -- Check excluded filetypes + for _, excluded in ipairs(cfg.filetypes_exclude or {}) do + if filetype == excluded then + return false + end + end + + return state.enabled +end + +--- Cancel the debounce timer if active +local function cancel_debounce_timer() + if state.debounce_timer then + if state.debounce_timer:is_active() then + state.debounce_timer:stop() + end + state.debounce_timer = nil + end +end + +--- Cancel current HTTP request if any +local function cancel_current_request() + load_deps() + + if state.current_handle then + http.cancel(state.current_handle) + state.current_handle = nil + end +end + +--- Deduplicate completion by removing text that already exists after cursor +---@param lines string[] The completion lines +---@param bufnr number Buffer number +---@param row number 0-indexed row +---@param col number 0-indexed column +---@return string[]|nil Deduplicated lines or nil if nothing left +local function deduplicate_completion(lines, bufnr, row, col) + if not lines or #lines == 0 then + return nil + end + + -- Get text after cursor on current line + local ok, current_lines = pcall(vim.api.nvim_buf_get_lines, bufnr, row, row + 1, false) + if not ok or #current_lines == 0 then + return lines + end + + local current_line = current_lines[1] or '' + local text_after_cursor = current_line:sub(col + 1) + + -- If there's text after cursor, check if completion duplicates it + if text_after_cursor ~= '' then + local first_line = lines[1] + + -- Check if completion starts with what's already there + if first_line:sub(1, #text_after_cursor) == text_after_cursor then + -- Make a copy to avoid modifying original + local deduped = {} + for i, line in ipairs(lines) do + deduped[i] = line + end + + -- Trim the duplicate part from first line + deduped[1] = first_line:sub(#text_after_cursor + 1) + + -- If first line is now empty + if deduped[1] == '' then + if #deduped > 1 then + -- Remove empty first line, keep rest + table.remove(deduped, 1) + else + -- Nothing left to suggest + return nil + end + end + + return deduped + end + end + + return lines +end + +--- Handle successful completion response +---@param response table The parsed JSON response from llama.cpp +---@param cache_key string|nil The cache key to store the result under +local function on_completion_success(response, cache_key) + load_deps() + + local cfg = config.get() + + -- Calculate latency + local latency_ms = nil + if state.request_start_time then + latency_ms = math.floor((vim.loop.now() - state.request_start_time)) + end + + -- Track latency for debug info + state.last_latency_ms = latency_ms + state.pending = false + + -- Parse the response (parser accepts both string and table) + local result = parser.parse(response, { + stop_tokens = { '\n\n', '<|endoftext|>' }, + }) + + -- Check if result is empty + if parser.is_empty(result) then + ui.clear() + return + end + + -- Store in cache if we have a cache key + if cache_key and result.lines and #result.lines > 0 then + cache.set(cache_key, { + lines = result.lines, + tokens_predicted = result.tokens_predicted, + }) + end + + -- Get current cursor position + local cursor = vim.api.nvim_win_get_cursor(0) + local row = cursor[1] - 1 -- Convert to 0-indexed + local col = cursor[2] + local bufnr = vim.api.nvim_get_current_buf() + + -- Deduplicate: remove text that already exists after cursor + local display_lines = deduplicate_completion(result.lines, bufnr, row, col) + if not display_lines or #display_lines == 0 then + ui.clear() + return + end + + -- Build info for display + local info = { + tokens = result.tokens_predicted, + latency_ms = latency_ms or (result.timings and result.timings.predicted_ms), + } + + -- Show completion in UI + ui.show({ + lines = display_lines, + bufnr = bufnr, + row = row, + col = col, + info = info, + }) + + -- Clear the request handle + state.current_handle = nil +end + +--- Handle completion error +---@param error_msg string The error message +local function on_completion_error(error_msg) + load_deps() + + -- Update pending state + state.pending = false + + -- Clear UI on error + ui.clear() + + -- Clear the request handle + state.current_handle = nil +end + +--- Send the HTTP request with the built context +---@param full_prefix string The complete prefix with all context +---@param input_suffix string The suffix from FIM +---@param cache_key string The cache key for storing results +local function send_completion_request(full_prefix, input_suffix, cache_key) + load_deps() + local cfg = config.get() + + -- Build the full request body for llama.cpp + local request_body = { + input_prefix = full_prefix, + input_suffix = input_suffix, + n_predict = cfg.server.n_predict, + temperature = cfg.server.temperature, + cache_prompt = cfg.server.cache_prompt, + stop = { '\n\n', '<|endoftext|>' }, + } + + -- Track request start time and pending state + state.request_start_time = vim.loop.now() + state.pending = true + + -- Make HTTP request + state.current_handle = http.request({ + endpoint = cfg.server.endpoint .. '/infill', + body = request_body, + timeout = cfg.server.timeout, + on_success = function(response) + vim.schedule(function() + on_completion_success(response, cache_key) + end) + end, + on_error = function(err) + vim.schedule(function() + on_completion_error(err) + end) + end, + }) +end + +--- Add sync context (ring buffer, edit tracking) to prefix +---@param prefix string The current prefix +---@return string The prefix with sync context added +local function add_sync_context(prefix) + local full_prefix = prefix + + -- Add ring buffer context + local ring_context = ring.get_context() + if ring_context and ring_context ~= '' then + full_prefix = ring_context .. '\n\n' .. full_prefix + end + + -- Add edit tracking context (Sweep's next-edit format) + -- This goes first in the prompt as it's most relevant for predicting next edits + local edit_context = edits.get_context() + if edit_context and edit_context ~= '' then + full_prefix = edit_context .. '\n\n' .. full_prefix + end + + return full_prefix +end + +--- Make a completion request +local function make_request() + load_deps() + + local cfg = config.get() + local bufnr = vim.api.nvim_get_current_buf() + local cursor = vim.api.nvim_win_get_cursor(0) + local row = cursor[1] - 1 -- Convert to 0-indexed + local col = cursor[2] + local filename = vim.api.nvim_buf_get_name(bufnr) + + -- Increment request ID to invalidate any pending async callbacks + state.request_id = state.request_id + 1 + local current_request_id = state.request_id + + -- Cancel any existing request + cancel_current_request() + + -- Build FIM request + local fim_request = fim.build_request({ + bufnr = bufnr, + row = row, + col = col, + prefix_lines = cfg.context.prefix_lines, + suffix_lines = cfg.context.suffix_lines, + }) + + -- Build cache key + local cache_key = cache.make_key({ + prefix = fim_request.input_prefix, + suffix = fim_request.input_suffix, + filename = filename, + }) + + -- Check cache first + local cached_result = cache.get(cache_key) + if cached_result then + -- Deduplicate cached result before showing (cursor context may differ) + local display_lines = deduplicate_completion(cached_result.lines, bufnr, row, col) + if not display_lines or #display_lines == 0 then + ui.clear() + return + end + + ui.show({ + lines = display_lines, + bufnr = bufnr, + row = row, + col = col, + info = { + tokens = cached_result.tokens_predicted, + latency_ms = 0, -- Cached, no latency + }, + }) + return + end + + -- Build the base prefix + local base_prefix = fim_request.input_prefix + local input_suffix = fim_request.input_suffix + local ctx_config = cfg.context or {} + + -- Check if we need async LSP context + if ctx_config.use_lsp then + -- Use async context gathering to get LSP definitions and type info + context.get_async({ + bufnr = bufnr, + row = row, + col = col, + use_lsp = true, + use_treesitter = ctx_config.use_treesitter, + }, function(ctx_result) + -- This callback runs after LSP responds (or times out) + -- Check if this callback is stale (a new request has been started) + if current_request_id ~= state.request_id then + return + end + + -- Check if request was cancelled while waiting for LSP + if not state.enabled then + return + end + + local full_prefix = base_prefix + + -- Add LSP/treesitter context + if ctx_result.formatted and ctx_result.formatted ~= '' then + full_prefix = ctx_result.formatted .. '\n\n' .. full_prefix + end + + -- Add sync context (ring buffer, edits) + full_prefix = add_sync_context(full_prefix) + + -- Send the request + send_completion_request(full_prefix, input_suffix, cache_key) + end) + else + -- Sync path: treesitter only (no LSP) + local full_prefix = base_prefix + + if ctx_config.use_treesitter then + local ctx_result = context.get({ + bufnr = bufnr, + row = row, + col = col, + use_lsp = false, + use_treesitter = true, + }) + if ctx_result.formatted and ctx_result.formatted ~= '' then + full_prefix = ctx_result.formatted .. '\n\n' .. full_prefix + end + end + + -- Add sync context (ring buffer, edits) + full_prefix = add_sync_context(full_prefix) + + -- Send the request + send_completion_request(full_prefix, input_suffix, cache_key) + end +end + +--- Trigger a completion request with debouncing +--- Usually called from autocmd (e.g., CursorMovedI) +function M.trigger() + if not should_trigger() then + return + end + + load_deps() + local cfg = config.get() + + -- Cancel existing debounce timer + cancel_debounce_timer() + + -- Create new debounce timer + state.debounce_timer = vim.loop.new_timer() + state.debounce_timer:start(cfg.debounce_ms, 0, vim.schedule_wrap(function() + cancel_debounce_timer() + make_request() + end)) +end + +--- Manually trigger completion immediately (no debounce) +function M.manual_trigger() + if not should_trigger() then + return + end + + -- Cancel any pending debounce + cancel_debounce_timer() + + -- Make request immediately + make_request() +end + +--- Cancel current completion request and clear UI +function M.cancel() + cancel_debounce_timer() + cancel_current_request() + + load_deps() + ui.clear() +end + +--- Insert text at the stored completion position +---@param text string The text to insert +---@param completion_data table The completion data from UI +local function insert_text(text, completion_data) + if not text or text == '' then + return + end + + local bufnr = completion_data.bufnr + local row = completion_data.row + local col = completion_data.col + + -- Split text into lines + local lines = vim.split(text, '\n', { plain = true }) + + -- Get the current line content + local current_line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + + -- Build the new content + local before_cursor = current_line:sub(1, col) + local after_cursor = current_line:sub(col + 1) + + if #lines == 1 then + -- Single line insertion + local new_line = before_cursor .. lines[1] .. after_cursor + vim.api.nvim_buf_set_lines(bufnr, row, row + 1, false, { new_line }) + -- Move cursor to end of inserted text + vim.api.nvim_win_set_cursor(0, { row + 1, col + #lines[1] }) + else + -- Multi-line insertion + local new_lines = {} + -- First line: before_cursor + first part of insertion + table.insert(new_lines, before_cursor .. lines[1]) + -- Middle lines: just the insertion content + for i = 2, #lines - 1 do + table.insert(new_lines, lines[i]) + end + -- Last line: last part of insertion + after_cursor + table.insert(new_lines, lines[#lines] .. after_cursor) + + vim.api.nvim_buf_set_lines(bufnr, row, row + 1, false, new_lines) + -- Move cursor to end of inserted text on last inserted line + local final_row = row + #lines + local final_col = #lines[#lines] + vim.api.nvim_win_set_cursor(0, { final_row, final_col }) + end +end + +--- Accept the full completion +function M.accept_full() + load_deps() + + local current = ui.get_current() + if not current then + return + end + + -- Join all lines into text + local text = table.concat(current.lines, '\n') + + -- Clear UI first + ui.clear() + + -- Insert the text + insert_text(text, current) +end + +--- Accept only the first line of the completion +function M.accept_line() + load_deps() + + local current = ui.get_current() + if not current then + return + end + + -- Get only the first line + local text = current.lines[1] or '' + + -- Clear UI first + ui.clear() + + -- Insert the text + insert_text(text, current) +end + +--- Accept only the first word of the completion +function M.accept_word() + load_deps() + + local current = ui.get_current() + if not current then + return + end + + -- Use parser's first_word extraction (handles leading whitespace) + local text = parser.first_word({ + content = table.concat(current.lines, '\n'), + }) + + -- Clear UI first + ui.clear() + + -- Insert the text + insert_text(text, current) +end + +--- Dismiss completion without inserting +function M.dismiss() + cancel_debounce_timer() + cancel_current_request() + + load_deps() + ui.clear() +end + +--- Enable completion +function M.enable() + state.enabled = true +end + +--- Disable completion +function M.disable() + M.cancel() + state.enabled = false +end + +--- Toggle completion on/off +function M.toggle() + if state.enabled then + M.disable() + else + M.enable() + end +end + +--- Check if completion is enabled +---@return boolean +function M.is_enabled() + return state.enabled +end + +--- Get completion state for debugging +---@return table +function M.get_state() + return { + pending = state.pending, + last_latency_ms = state.last_latency_ms, + enabled = state.enabled, + } +end + +return M diff --git a/sweep.nvim/lua/sweep/config.lua b/sweep.nvim/lua/sweep/config.lua new file mode 100644 index 0000000..5a3289f --- /dev/null +++ b/sweep.nvim/lua/sweep/config.lua @@ -0,0 +1,108 @@ +-- sweep.nvim - Configuration module + +local M = {} + +---@class SweepKeymaps +---@field trigger string Keymap to manually trigger completion +---@field accept_full string Keymap to accept full completion +---@field accept_line string Keymap to accept first line +---@field accept_word string Keymap to accept first word +---@field dismiss string Keymap to dismiss completion + +---@class SweepContextConfig +---@field prefix_lines number Lines of context before cursor +---@field suffix_lines number Lines of context after cursor +---@field max_ring_chunks number Maximum chunks in ring buffer +---@field chunk_size number Lines per chunk +---@field use_lsp boolean Use LSP for rich context +---@field use_treesitter boolean Use treesitter for scope context + +---@class SweepServerConfig +---@field endpoint string llama.cpp server endpoint +---@field timeout number Request timeout in milliseconds +---@field n_predict number Maximum tokens to predict +---@field temperature number Sampling temperature +---@field cache_prompt boolean Enable llama.cpp prompt caching + +---@class SweepUIConfig +---@field show_info boolean Show completion info (tokens, latency) +---@field hl_group string Highlight group for ghost text +---@field info_hl_group string Highlight group for info text + +---@class SweepConfig +---@field auto_enable boolean Auto-enable on setup +---@field debounce_ms number Debounce delay in milliseconds +---@field keymaps SweepKeymaps +---@field context SweepContextConfig +---@field server SweepServerConfig +---@field ui SweepUIConfig +---@field filetypes_exclude string[] Filetypes to exclude + +---@type SweepConfig +M.defaults = { + auto_enable = true, + debounce_ms = 100, + show_info = true, + + keymaps = { + trigger = '', + accept_full = '', + accept_line = '', + accept_word = '', + dismiss = '', + }, + + context = { + prefix_lines = 100, + suffix_lines = 50, + max_ring_chunks = 16, + chunk_size = 64, + use_lsp = true, + use_treesitter = true, + }, + + server = { + endpoint = os.getenv('LLAMA_SERVER_URL') or 'http://localhost:8080', + timeout = 5000, + n_predict = 128, + temperature = 0.1, + cache_prompt = true, + }, + + ui = { + show_info = true, + hl_group = 'SweepGhostText', + info_hl_group = 'SweepInfo', + }, + + filetypes_exclude = { + 'help', + 'TelescopePrompt', + 'lazy', + 'mason', + 'neo-tree', + 'NvimTree', + 'toggleterm', + }, +} + +---@type SweepConfig +M.options = vim.deepcopy(M.defaults) + +--- Setup configuration with user options +---@param opts? table User options to merge with defaults +function M.setup(opts) + M.options = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), opts or {}) + + -- Set up highlight groups + vim.api.nvim_set_hl(0, 'SweepGhostText', { link = 'Comment', default = true }) + vim.api.nvim_set_hl(0, 'SweepInfo', { link = 'DiagnosticInfo', default = true }) +end + +--- Get current configuration +---@return SweepConfig +function M.get() + return M.options +end + +return M diff --git a/sweep.nvim/lua/sweep/context.lua b/sweep.nvim/lua/sweep/context.lua new file mode 100644 index 0000000..089cc1c --- /dev/null +++ b/sweep.nvim/lua/sweep/context.lua @@ -0,0 +1,649 @@ +-- sweep.nvim - LSP and treesitter context extraction module +-- Extracts rich context using LSP for definitions and treesitter for scope analysis + +local M = {} + +---@class SweepDefinition +---@field name string Symbol name +---@field kind string Symbol kind (class, function, variable, etc.) +---@field content string Definition content +---@field filename? string Source filename + +---@class SweepScope +---@field type string Scope type (function, class, method, module) +---@field name? string Scope name if available +---@field range {start_row: number, end_row: number} Line range +---@field content string Scope content (may be truncated) + +---@class SweepContextResult +---@field definitions SweepDefinition[] LSP-derived definitions +---@field type_info? string Type information at cursor +---@field scope? SweepScope Enclosing scope from treesitter +---@field imports string[] Import/require statements +---@field formatted string Formatted context for prompts + +---@class SweepContextOpts +---@field bufnr number Buffer number +---@field row number 0-indexed cursor row +---@field col number 0-indexed cursor column +---@field use_lsp? boolean Use LSP for context (default: from config) +---@field use_treesitter? boolean Use treesitter for context (default: from config) +---@field max_definition_lines? number Max lines per definition (default: 50) +---@field max_scope_lines? number Max lines for scope content (default: 50) +---@field max_formatted_lines? number Max lines for formatted output (default: 200) + +-- Default configuration values +local DEFAULT_MAX_DEFINITION_LINES = 50 +local DEFAULT_MAX_SCOPE_LINES = 50 +local DEFAULT_MAX_FORMATTED_LINES = 200 +local DEFAULT_MAX_IMPORT_SCAN_LINES = 50 + +-- Scope node types to look for when walking up the tree +-- Note: All languages share the same node type names where applicable +local SCOPE_NODE_TYPES = { + -- Function-like scopes (Lua, Python, JS/TS, Go) + ['function_declaration'] = 'function', + ['function_definition'] = 'function', + ['local_function'] = 'function', + ['function'] = 'function', + ['arrow_function'] = 'function', + ['function_item'] = 'function', -- Rust + -- Method scopes + ['method_definition'] = 'method', + ['method_declaration'] = 'method', + -- Class-like scopes + ['class_definition'] = 'class', + ['class_declaration'] = 'class', + ['type_declaration'] = 'class', + ['impl_item'] = 'class', -- Rust + ['struct_item'] = 'class', -- Rust +} + +-- Import patterns by filetype +local IMPORT_PATTERNS = { + lua = { + '^%s*local%s+[%w_]+%s*=%s*require%s*%(?%s*["\']', + '^%s*require%s*%(?%s*["\']', + }, + python = { + '^%s*import%s+', + '^%s*from%s+[%w_.]+%s+import%s+', + }, + javascript = { + '^%s*import%s+', + '^%s*const%s+[%w_]+%s*=%s*require%s*%(', + '^%s*let%s+[%w_]+%s*=%s*require%s*%(', + '^%s*var%s+[%w_]+%s*=%s*require%s*%(', + }, + typescript = { + '^%s*import%s+', + '^%s*const%s+[%w_]+%s*=%s*require%s*%(', + }, + go = { + '^%s*import%s+', + }, + rust = { + '^%s*use%s+', + }, +} + +-- Add aliases +IMPORT_PATTERNS.javascriptreact = IMPORT_PATTERNS.javascript +IMPORT_PATTERNS.typescriptreact = IMPORT_PATTERNS.typescript + +--- Get configuration with defaults +---@return table +local function get_config() + local ok, config = pcall(require, 'sweep.config') + if ok then + return config.get() + end + return { + context = { + use_lsp = true, + use_treesitter = true, + }, + } +end + +--- Get buffer lines safely +---@param bufnr number Buffer number +---@param start_row number Start row (0-indexed) +---@param end_row number End row (0-indexed, exclusive) or -1 for all +---@return string[] lines +local function get_buffer_lines(bufnr, start_row, end_row) + local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, start_row, end_row, false) + if not ok then + return {} + end + return lines +end + +--- Get filetype for buffer +---@param bufnr number Buffer number +---@return string filetype +local function get_filetype(bufnr) + return vim.api.nvim_get_option_value('filetype', { buf = bufnr }) +end + +--- Walk up the tree to find enclosing scope node +---@param node any Treesitter node +---@return any|nil scope_node, string|nil scope_type +local function find_enclosing_scope(node) + local current = node + while current do + local node_type = current:type() + local scope_type = SCOPE_NODE_TYPES[node_type] + if scope_type then + return current, scope_type + end + current = current:parent() + end + return nil, nil +end + +--- Try to extract name from a scope node +---@param node any Treesitter node +---@param bufnr number Buffer number +---@return string|nil name +local function get_scope_name(node, bufnr) + -- Try common field names for function/class names + local name_fields = { 'name', 'declarator' } + + for _, field_name in ipairs(name_fields) do + local ok, children = pcall(function() return node:field(field_name) end) + if ok and children and #children > 0 then + local name_node = children[1] + if name_node then + -- Try to get text from the name node + local ok2, text = pcall(vim.treesitter.get_node_text, name_node, bufnr) + if ok2 and text then + return text + end + end + end + end + + return nil +end + +--- Get the current scope at cursor position using treesitter +---@param bufnr number Buffer number +---@param row number 0-indexed cursor row +---@param opts? {max_lines?: number} Options +---@return SweepScope|nil scope +function M.get_scope(bufnr, row, opts) + opts = opts or {} + local max_lines = opts.max_lines or DEFAULT_MAX_SCOPE_LINES + + -- Try to get treesitter node at cursor + local ok, node = pcall(vim.treesitter.get_node, { + bufnr = bufnr, + pos = { row, 0 }, + }) + + if not ok or not node then + return nil + end + + -- Find enclosing scope + local scope_node, scope_type = find_enclosing_scope(node) + if not scope_node then + return nil + end + + -- Get range + local start_row = scope_node:start() + local end_row = scope_node:end_() + + -- Get scope name + local name = get_scope_name(scope_node, bufnr) + + -- Get content (potentially truncated) + local content_end = math.min(end_row + 1, start_row + max_lines) + local lines = get_buffer_lines(bufnr, start_row, content_end) + local content = table.concat(lines, '\n') + + -- Add truncation marker if needed + if end_row - start_row + 1 > max_lines then + content = content .. '\n-- ... truncated ...' + end + + return { + type = scope_type, + name = name, + range = { + start_row = start_row, + end_row = end_row, + }, + content = content, + } +end + +--- Extract import statements from buffer +---@param bufnr number Buffer number +---@param opts? {max_lines?: number} Options +---@return string[] imports +function M.get_imports(bufnr, opts) + opts = opts or {} + local max_lines = opts.max_lines or DEFAULT_MAX_IMPORT_SCAN_LINES + + local filetype = get_filetype(bufnr) + local patterns = IMPORT_PATTERNS[filetype] + + -- If no specific patterns, try to detect common patterns + if not patterns then + patterns = { + '^%s*import%s+', + '^%s*require%s*%(', + '^%s*from%s+', + '^%s*use%s+', + } + end + + local lines = get_buffer_lines(bufnr, 0, max_lines) + local imports = {} + local blank_line_count = 0 + + for i, line in ipairs(lines) do + -- Stop scanning after too many blank lines (likely past import section) + if line:match('^%s*$') then + blank_line_count = blank_line_count + 1 + if blank_line_count > 3 then + -- Check if we're still in import section by looking ahead + local found_import = false + for j = i + 1, math.min(i + 5, #lines) do + for _, pattern in ipairs(patterns) do + if lines[j] and lines[j]:match(pattern) then + found_import = true + break + end + end + if found_import then break end + end + if not found_import then + break + end + end + else + blank_line_count = 0 + end + + -- Check if line matches any import pattern + for _, pattern in ipairs(patterns) do + if line:match(pattern) then + table.insert(imports, line) + break + end + end + end + + return imports +end + +--- Get definitions at cursor position via LSP (async) +---@param opts {bufnr: number, row: number, col: number, max_lines?: number} Options +---@param callback fun(definitions: SweepDefinition[]) Callback with results +function M.get_definitions(opts, callback) + local bufnr = opts.bufnr + local row = opts.row + local col = opts.col + local max_lines = opts.max_lines or DEFAULT_MAX_DEFINITION_LINES + + -- Check for LSP clients + local clients = vim.lsp.get_clients({ bufnr = bufnr }) + if not clients or #clients == 0 then + callback({}) + return + end + + -- Create LSP position params + local params = { + textDocument = { + uri = vim.uri_from_bufnr(bufnr), + }, + position = { + line = row, + character = col, + }, + } + + -- Request definitions from LSP + local success, _ = vim.lsp.buf_request(bufnr, 'textDocument/definition', params, function(err, result, ctx) + if err or not result then + callback({}) + return + end + + local definitions = {} + + -- Normalize result to array + local locations = vim.islist(result) and result or { result } + + for _, location in ipairs(locations) do + if location and location.uri then + local def_bufnr = vim.uri_to_bufnr(location.uri) + local range = location.range or location.targetSelectionRange or location.targetRange + + if range then + local start_line = range.start.line + local end_line = range['end'].line + + -- Limit content size + local content_end = math.min(end_line + 1, start_line + max_lines) + + -- Try to load buffer if not loaded + local loaded = vim.api.nvim_buf_is_loaded(def_bufnr) + if not loaded then + pcall(vim.fn.bufload, def_bufnr) + end + + local lines = get_buffer_lines(def_bufnr, start_line, content_end) + local content = table.concat(lines, '\n') + + -- Extract name from first line if possible + local name = 'unknown' + if lines[1] then + -- Try to extract identifier from first line + name = lines[1]:match('[%w_]+') or 'unknown' + end + + local filename = vim.fn.fnamemodify(vim.uri_to_fname(location.uri), ':t') + + table.insert(definitions, { + name = name, + kind = 'definition', + content = content, + filename = filename, + }) + end + end + end + + callback(definitions) + end) + + if not success then + callback({}) + end +end + +--- Get type information at cursor via LSP (async) +---@param opts {bufnr: number, row: number, col: number} Options +---@param callback fun(type_info: string|nil) Callback with result +function M.get_type_info(opts, callback) + local bufnr = opts.bufnr + local row = opts.row + local col = opts.col + + -- Check for LSP clients + local clients = vim.lsp.get_clients({ bufnr = bufnr }) + if not clients or #clients == 0 then + callback(nil) + return + end + + -- Create LSP position params + local params = { + textDocument = { + uri = vim.uri_from_bufnr(bufnr), + }, + position = { + line = row, + character = col, + }, + } + + -- Request hover info from LSP + local success, _ = vim.lsp.buf_request(bufnr, 'textDocument/hover', params, function(err, result, ctx) + if err or not result or not result.contents then + callback(nil) + return + end + + -- Extract type from hover contents + local contents = result.contents + local type_info = nil + + if type(contents) == 'string' then + type_info = contents + elseif type(contents) == 'table' then + if contents.value then + type_info = contents.value + elseif contents.kind and contents.value then + type_info = contents.value + end + end + + callback(type_info) + end) + + if not success then + callback(nil) + end +end + +--- Format context sections into a string for prompts +---@param ctx {definitions?: SweepDefinition[], scope?: SweepScope, imports?: string[], type_info?: string} Context parts +---@param opts? {max_lines?: number} Options +---@return string formatted +local function format_context(ctx, opts) + opts = opts or {} + local max_lines = opts.max_lines or DEFAULT_MAX_FORMATTED_LINES + + local sections = {} + local total_lines = 0 + + -- Priority 1: Current scope + if ctx.scope and ctx.scope.content then + local scope_header = string.format('-- Current scope (%s%s):', + ctx.scope.type, + ctx.scope.name and ': ' .. ctx.scope.name or '') + local scope_lines = select(2, ctx.scope.content:gsub('\n', '\n')) + 1 + + if total_lines + scope_lines + 2 <= max_lines then + table.insert(sections, scope_header) + table.insert(sections, ctx.scope.content) + table.insert(sections, '') + total_lines = total_lines + scope_lines + 2 + end + end + + -- Priority 2: Type info + if ctx.type_info then + local type_section = '-- Type at cursor: ' .. ctx.type_info + if total_lines + 2 <= max_lines then + table.insert(sections, type_section) + table.insert(sections, '') + total_lines = total_lines + 2 + end + end + + -- Priority 3: Definitions + if ctx.definitions and #ctx.definitions > 0 then + local def_header = '-- Definitions:' + table.insert(sections, def_header) + total_lines = total_lines + 1 + + for _, def in ipairs(ctx.definitions) do + local def_lines = select(2, def.content:gsub('\n', '\n')) + 1 + if total_lines + def_lines + 2 <= max_lines then + local def_label = string.format('-- %s (%s)%s:', + def.name, + def.kind, + def.filename and ' from ' .. def.filename or '') + table.insert(sections, def_label) + table.insert(sections, def.content) + table.insert(sections, '') + total_lines = total_lines + def_lines + 2 + else + table.insert(sections, '-- ... more definitions truncated ...') + break + end + end + end + + -- Priority 4: Imports + if ctx.imports and #ctx.imports > 0 then + local imports_header = '-- Imports:' + local imports_lines = #ctx.imports + + if total_lines + imports_lines + 2 <= max_lines then + table.insert(sections, imports_header) + for _, import in ipairs(ctx.imports) do + table.insert(sections, import) + end + table.insert(sections, '') + total_lines = total_lines + imports_lines + 2 + elseif total_lines + 5 <= max_lines then + -- Include truncated imports + table.insert(sections, imports_header) + local remaining = max_lines - total_lines - 2 + for i = 1, math.min(remaining, #ctx.imports) do + table.insert(sections, ctx.imports[i]) + end + if #ctx.imports > remaining then + table.insert(sections, '-- ... more imports truncated ...') + end + end + end + + return table.concat(sections, '\n') +end + +--- Get rich context for current cursor position (main entry point) +---@param opts SweepContextOpts Options +---@return SweepContextResult context +function M.get(opts) + local config = get_config() + local ctx_config = config.context or {} + + local bufnr = opts.bufnr or 0 + local row = opts.row or 0 + local col = opts.col or 0 + local use_lsp = opts.use_lsp + local use_treesitter = opts.use_treesitter + + -- Use config defaults if not specified + if use_lsp == nil then + use_lsp = ctx_config.use_lsp ~= false + end + if use_treesitter == nil then + use_treesitter = ctx_config.use_treesitter ~= false + end + + local max_definition_lines = opts.max_definition_lines or DEFAULT_MAX_DEFINITION_LINES + local max_scope_lines = opts.max_scope_lines or DEFAULT_MAX_SCOPE_LINES + local max_formatted_lines = opts.max_formatted_lines or DEFAULT_MAX_FORMATTED_LINES + + -- Initialize result + local result = { + definitions = {}, + type_info = nil, + scope = nil, + imports = {}, + formatted = '', + } + + -- Get treesitter scope + if use_treesitter then + result.scope = M.get_scope(bufnr, row, { max_lines = max_scope_lines }) + end + + -- Get imports (always try to get these as they're useful) + result.imports = M.get_imports(bufnr) + + -- Note: LSP calls are async, but for the synchronous get() API, + -- we return what we can synchronously and leave definitions empty. + -- For LSP integration, use get_definitions() directly with a callback. + + -- Format the context + result.formatted = format_context({ + definitions = result.definitions, + scope = result.scope, + imports = result.imports, + type_info = result.type_info, + }, { max_lines = max_formatted_lines }) + + return result +end + +--- Async version that waits for LSP results +---@param opts SweepContextOpts Options +---@param callback fun(context: SweepContextResult) Callback with full context +function M.get_async(opts, callback) + local config = get_config() + local ctx_config = config.context or {} + + local bufnr = opts.bufnr or 0 + local row = opts.row or 0 + local col = opts.col or 0 + local use_lsp = opts.use_lsp + local use_treesitter = opts.use_treesitter + + -- Use config defaults if not specified + if use_lsp == nil then + use_lsp = ctx_config.use_lsp ~= false + end + if use_treesitter == nil then + use_treesitter = ctx_config.use_treesitter ~= false + end + + local max_definition_lines = opts.max_definition_lines or DEFAULT_MAX_DEFINITION_LINES + local max_scope_lines = opts.max_scope_lines or DEFAULT_MAX_SCOPE_LINES + local max_formatted_lines = opts.max_formatted_lines or DEFAULT_MAX_FORMATTED_LINES + + -- Initialize result + local result = { + definitions = {}, + type_info = nil, + scope = nil, + imports = {}, + formatted = '', + } + + -- Get synchronous parts + if use_treesitter then + result.scope = M.get_scope(bufnr, row, { max_lines = max_scope_lines }) + end + result.imports = M.get_imports(bufnr) + + -- If not using LSP, return immediately + if not use_lsp then + result.formatted = format_context(result, { max_lines = max_formatted_lines }) + callback(result) + return + end + + -- Get async LSP parts + local pending = 2 -- definitions + type_info + + local function check_complete() + pending = pending - 1 + if pending == 0 then + result.formatted = format_context(result, { max_lines = max_formatted_lines }) + callback(result) + end + end + + M.get_definitions({ + bufnr = bufnr, + row = row, + col = col, + max_lines = max_definition_lines, + }, function(definitions) + result.definitions = definitions + check_complete() + end) + + M.get_type_info({ + bufnr = bufnr, + row = row, + col = col, + }, function(type_info) + result.type_info = type_info + check_complete() + end) +end + +return M diff --git a/sweep.nvim/lua/sweep/debug.lua b/sweep.nvim/lua/sweep/debug.lua new file mode 100644 index 0000000..4d922a2 --- /dev/null +++ b/sweep.nvim/lua/sweep/debug.lua @@ -0,0 +1,265 @@ +-- sweep.nvim - Debug module for inspecting plugin state +-- Provides introspection and debugging utilities + +local M = {} + +-- Debug pane state +local pane_state = { + bufnr = nil, + winid = nil, +} + +--- Get comprehensive debug information about the plugin state +---@return table Debug info with enabled, completion, cache, ring, and config sections +function M.get_info() + local ok_config, config = pcall(require, 'sweep.config') + local ok_completion, completion = pcall(require, 'sweep.completion') + local ok_cache, cache = pcall(require, 'sweep.cache') + local ok_ring, ring = pcall(require, 'sweep.ring') + local ok_init, sweep = pcall(require, 'sweep') + + local info = { + enabled = false, + completion = { + pending = false, + last_latency_ms = nil, + }, + cache = { + entries = 0, + hits = 0, + misses = 0, + }, + ring = { + chunks = 0, + filetypes = {}, + }, + config = {}, + } + + -- Get enabled state from main module + if ok_init and sweep then + info.enabled = sweep.is_enabled() + end + + -- Get completion state + if ok_completion and completion.get_state then + local comp_state = completion.get_state() + info.completion = { + pending = comp_state.pending or false, + last_latency_ms = comp_state.last_latency_ms, + } + end + + -- Get cache stats + if ok_cache and cache.stats then + local cache_stats = cache.stats() + info.cache = { + entries = cache_stats.entries or 0, + hits = cache_stats.hits or 0, + misses = cache_stats.misses or 0, + } + end + + -- Get ring buffer stats + if ok_ring and ring.stats then + local ring_stats = ring.stats() + info.ring = { + chunks = ring_stats.count or 0, + filetypes = ring_stats.filetypes or {}, + } + end + + -- Get current config + if ok_config and config.get then + info.config = config.get() + end + + return info +end + +--- Format debug info for display +---@param info table Debug info from get_info() +---@return string[] Lines for display +local function format_debug_info(info) + local lines = {} + + table.insert(lines, '=== Sweep Debug Info ===') + table.insert(lines, '') + + -- Status + table.insert(lines, 'Status:') + table.insert(lines, string.format(' Enabled: %s', info.enabled and 'yes' or 'no')) + table.insert(lines, '') + + -- Completion + table.insert(lines, 'Completion:') + table.insert(lines, string.format(' Pending: %s', info.completion.pending and 'yes' or 'no')) + if info.completion.last_latency_ms then + table.insert(lines, string.format(' Last latency: %dms', info.completion.last_latency_ms)) + else + table.insert(lines, ' Last latency: N/A') + end + table.insert(lines, '') + + -- Cache + table.insert(lines, 'Cache:') + table.insert(lines, string.format(' Entries: %d', info.cache.entries)) + table.insert(lines, string.format(' Hits: %d', info.cache.hits)) + table.insert(lines, string.format(' Misses: %d', info.cache.misses)) + local total = info.cache.hits + info.cache.misses + if total > 0 then + local hit_rate = math.floor((info.cache.hits / total) * 100) + table.insert(lines, string.format(' Hit rate: %d%%', hit_rate)) + end + table.insert(lines, '') + + -- Ring buffer + table.insert(lines, 'Ring Buffer:') + table.insert(lines, string.format(' Chunks: %d', info.ring.chunks)) + if info.ring.filetypes and next(info.ring.filetypes) then + table.insert(lines, ' Filetypes:') + for ft, count in pairs(info.ring.filetypes) do + table.insert(lines, string.format(' %s: %d', ft, count)) + end + end + table.insert(lines, '') + + -- Config summary + table.insert(lines, 'Config:') + if info.config then + table.insert(lines, string.format(' Debounce: %dms', info.config.debounce_ms or 0)) + if info.config.server then + table.insert(lines, string.format(' Endpoint: %s', info.config.server.endpoint or 'N/A')) + table.insert(lines, string.format(' Timeout: %dms', info.config.server.timeout or 0)) + table.insert(lines, string.format(' Max tokens: %d', info.config.server.n_predict or 0)) + end + if info.config.context then + table.insert(lines, string.format(' Prefix lines: %d', info.config.context.prefix_lines or 0)) + table.insert(lines, string.format(' Suffix lines: %d', info.config.context.suffix_lines or 0)) + table.insert(lines, string.format(' Use LSP: %s', info.config.context.use_lsp and 'yes' or 'no')) + table.insert(lines, string.format(' Use Treesitter: %s', info.config.context.use_treesitter and 'yes' or 'no')) + end + if info.config.filetypes_exclude and #info.config.filetypes_exclude > 0 then + table.insert(lines, string.format(' Excluded filetypes: %s', table.concat(info.config.filetypes_exclude, ', '))) + end + end + table.insert(lines, '') + + table.insert(lines, '(Press q or to close)') + + return lines +end + +--- Check if debug pane is currently open +---@return boolean +function M.is_pane_open() + return pane_state.winid ~= nil + and vim.api.nvim_win_is_valid(pane_state.winid) +end + +--- Close the debug pane +function M.close_pane() + if pane_state.winid and vim.api.nvim_win_is_valid(pane_state.winid) then + vim.api.nvim_win_close(pane_state.winid, true) + end + if pane_state.bufnr and vim.api.nvim_buf_is_valid(pane_state.bufnr) then + vim.api.nvim_buf_delete(pane_state.bufnr, { force = true }) + end + pane_state.winid = nil + pane_state.bufnr = nil +end + +--- Show debug pane with current plugin state +function M.show_pane() + -- Close existing pane if open + if M.is_pane_open() then + M.close_pane() + end + + -- Get debug info + local info = M.get_info() + local lines = format_debug_info(info) + + -- Calculate window dimensions + local width = 50 + local height = #lines + + -- Get editor dimensions + local editor_width = vim.o.columns + local editor_height = vim.o.lines + + -- Center the window + local row = math.floor((editor_height - height) / 2) + local col = math.floor((editor_width - width) / 2) + + -- Create buffer + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- Buffer options + vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr }) + vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr }) + vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr }) + vim.api.nvim_set_option_value('filetype', 'sweep-debug', { buf = bufnr }) + + -- Create floating window + local winid = vim.api.nvim_open_win(bufnr, true, { + relative = 'editor', + width = width, + height = height, + row = row, + col = col, + style = 'minimal', + border = 'rounded', + title = ' Sweep Debug ', + title_pos = 'center', + }) + + -- Window options + vim.api.nvim_set_option_value('wrap', false, { win = winid }) + vim.api.nvim_set_option_value('cursorline', true, { win = winid }) + + -- Store state + pane_state.bufnr = bufnr + pane_state.winid = winid + + -- Set up keymaps to close + local close_keys = { 'q', '' } + for _, key in ipairs(close_keys) do + vim.keymap.set('n', key, function() + M.close_pane() + end, { buffer = bufnr, nowait = true }) + end + + -- Auto-close on BufLeave + vim.api.nvim_create_autocmd('BufLeave', { + buffer = bufnr, + once = true, + callback = function() + M.close_pane() + end, + }) +end + +--- Refresh the debug pane if open +function M.refresh_pane() + if M.is_pane_open() then + M.show_pane() + end +end + +--- Log a debug message (if debug mode is enabled) +---@param msg string Message to log +---@param level? number Vim log level (default: vim.log.levels.DEBUG) +function M.log(msg, level) + level = level or vim.log.levels.DEBUG + local ok, config = pcall(require, 'sweep.config') + if ok then + local cfg = config.get() + if cfg.debug then + vim.notify('[sweep] ' .. msg, level) + end + end +end + +return M diff --git a/sweep.nvim/lua/sweep/edits.lua b/sweep.nvim/lua/sweep/edits.lua new file mode 100644 index 0000000..e1c4208 --- /dev/null +++ b/sweep.nvim/lua/sweep/edits.lua @@ -0,0 +1,250 @@ +-- sweep.nvim - Edit tracking module for next-edit prediction context +-- Tracks recent edits using simple original/updated blocks (Sweep's research finding) + +local M = {} + +---@class EditRecord +---@field timestamp number Unix timestamp when edit was recorded +---@field filename string Full path to the edited file +---@field bufnr number Buffer number +---@field old_lines string[] Original lines before edit +---@field new_lines string[] Updated lines after edit + +---@class EditsConfig +---@field max_edits number Maximum number of edits to track +---@field max_lines number Maximum lines per edit +---@field context_lines number Lines of context around edits + +---@type EditRecord[] +local edit_history = {} + +---@type EditsConfig +local config = { + max_edits = 10, + max_lines = 20, + context_lines = 3, +} + +---@type table Track which buffers are attached +local attached_buffers = {} + +---@type table Store previous buffer content for diff computation +local buffer_content = {} + +--- Truncate lines array to max_lines +---@param lines string[] +---@param max_lines number +---@return string[] +local function truncate_lines(lines, max_lines) + if #lines <= max_lines then + return lines + end + local result = {} + for i = 1, max_lines do + result[i] = lines[i] + end + return result +end + +--- Check if two line arrays are identical +---@param lines1 string[] +---@param lines2 string[] +---@return boolean +local function lines_equal(lines1, lines2) + if #lines1 ~= #lines2 then + return false + end + for i = 1, #lines1 do + if lines1[i] ~= lines2[i] then + return false + end + end + return true +end + +--- Initialize edit tracking with configuration +---@param opts? EditsConfig +function M.setup(opts) + opts = opts or {} + config.max_edits = opts.max_edits or 10 + config.max_lines = opts.max_lines or 20 + config.context_lines = opts.context_lines or 3 + M.clear() +end + +--- Record an edit +---@param edit table { bufnr: number, start_line: number, end_line: number, old_lines: string[], new_lines: string[], filename?: string } +function M.record(edit) + if not edit then + return + end + + local old_lines = edit.old_lines or {} + local new_lines = edit.new_lines or {} + + -- Skip if both old and new are empty + if #old_lines == 0 and #new_lines == 0 then + return + end + + -- Skip if old and new are identical (no actual change) + if lines_equal(old_lines, new_lines) then + return + end + + -- Truncate lines if they exceed max_lines + old_lines = truncate_lines(old_lines, config.max_lines) + new_lines = truncate_lines(new_lines, config.max_lines) + + ---@type EditRecord + local record = { + timestamp = os.time(), + filename = edit.filename or '', + bufnr = edit.bufnr or 0, + old_lines = old_lines, + new_lines = new_lines, + } + + -- Insert at the beginning (most recent first) + table.insert(edit_history, 1, record) + + -- Evict oldest edits if over limit + while #edit_history > config.max_edits do + table.remove(edit_history) + end +end + +--- Get recent edits as formatted context string +--- Uses Sweep's research-backed format: ...... +---@return string +function M.get_context() + if #edit_history == 0 then + return '' + end + + local parts = {} + + for _, edit in ipairs(edit_history) do + local edit_parts = {} + table.insert(edit_parts, '') + table.insert(edit_parts, '') + for _, line in ipairs(edit.old_lines) do + table.insert(edit_parts, line) + end + table.insert(edit_parts, '') + table.insert(edit_parts, '') + for _, line in ipairs(edit.new_lines) do + table.insert(edit_parts, line) + end + table.insert(edit_parts, '') + table.insert(edit_parts, '') + + table.insert(parts, table.concat(edit_parts, '\n')) + end + + return table.concat(parts, '\n') +end + +--- Get raw edit history +---@return EditRecord[] +function M.get_history() + return edit_history +end + +--- Clear all edit history +function M.clear() + edit_history = {} +end + +--- Clear edits for a specific buffer +---@param bufnr number +function M.clear_buffer(bufnr) + local new_history = {} + for _, edit in ipairs(edit_history) do + if edit.bufnr ~= bufnr then + table.insert(new_history, edit) + end + end + edit_history = new_history + + -- Also clean up buffer content cache + buffer_content[bufnr] = nil +end + +--- Start tracking a buffer (attaches to buffer events) +---@param bufnr number +---@return boolean success +function M.attach(bufnr) + if attached_buffers[bufnr] then + return true + end + + -- Store initial buffer content for diff computation + local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, 0, -1, false) + if ok then + buffer_content[bufnr] = lines + end + + -- Attach to buffer with on_lines callback + local success = pcall(function() + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = function(_, buf, _, first_line, last_line, new_last_line, _, _, _) + -- Get the old content from our cache + local old_lines = {} + if buffer_content[buf] then + for i = first_line + 1, last_line do + if buffer_content[buf][i] then + table.insert(old_lines, buffer_content[buf][i]) + end + end + end + + -- Get the new content + local new_ok, new_lines = pcall(vim.api.nvim_buf_get_lines, buf, first_line, new_last_line, false) + if not new_ok then + new_lines = {} + end + + -- Get filename + local filename = vim.api.nvim_buf_get_name(buf) + + -- Record the edit + M.record({ + bufnr = buf, + start_line = first_line + 1, -- Convert to 1-indexed + end_line = new_last_line, + old_lines = old_lines, + new_lines = new_lines, + filename = filename, + }) + + -- Update our content cache + local full_ok, full_lines = pcall(vim.api.nvim_buf_get_lines, buf, 0, -1, false) + if full_ok then + buffer_content[buf] = full_lines + end + end, + on_detach = function(_, buf) + attached_buffers[buf] = nil + buffer_content[buf] = nil + end, + }) + end) + + if success then + attached_buffers[bufnr] = true + end + + return success +end + +--- Stop tracking a buffer +---@param bufnr number +function M.detach(bufnr) + attached_buffers[bufnr] = nil + buffer_content[bufnr] = nil + -- Note: nvim_buf_attach with on_detach handles cleanup automatically + -- when buffer is deleted. Manual detach is for explicit stop tracking. +end + +return M diff --git a/sweep.nvim/lua/sweep/fim.lua b/sweep.nvim/lua/sweep/fim.lua new file mode 100644 index 0000000..9e5aad2 --- /dev/null +++ b/sweep.nvim/lua/sweep/fim.lua @@ -0,0 +1,189 @@ +-- sweep.nvim - FIM (Fill-in-Middle) request builder module +-- Extracts context from the current buffer and builds FIM prompts + +local M = {} + +---@class FimTokens +---@field prefix string Token to mark prefix start +---@field suffix string Token to mark suffix start +---@field middle string Token to mark middle (completion) start + +---@class FimRequestOptions +---@field bufnr number Buffer number +---@field row number 0-indexed cursor row +---@field col number 0-indexed cursor column +---@field prefix_lines number Lines of context before cursor +---@field suffix_lines number Lines of context after cursor +---@field format? 'infill'|'fim_tokens' Output format (default: 'infill') +---@field fim_tokens? FimTokens Custom FIM tokens (only used with 'fim_tokens' format) + +---@class FimMetadata +---@field cursor_line string Full text of the current line +---@field indent string Detected indentation of current line +---@field filename string Filename (without path) +---@field filetype string Buffer filetype + +---@class FimRequest +---@field input_prefix? string Code before cursor (infill format) +---@field input_suffix? string Code after cursor (infill format) +---@field prompt? string Full FIM prompt with tokens (fim_tokens format) +---@field metadata FimMetadata Additional context information + +-- Default FIM tokens (CodeLlama/DeepSeek style) +local DEFAULT_FIM_TOKENS = { + prefix = '
',
+  suffix = '',
+  middle = '',
+}
+
+--- Detect the leading whitespace (indentation) of a line
+---@param line string The line to analyze
+---@return string The indentation string (spaces and/or tabs)
+local function detect_indent(line)
+  if not line or line == '' then
+    return ''
+  end
+  local indent = line:match('^[ \t]*')
+  return indent or ''
+end
+
+--- Extract filename from buffer path
+---@param bufnr number Buffer number
+---@return string Filename or empty string
+local function get_filename(bufnr)
+  local full_path = vim.api.nvim_buf_get_name(bufnr)
+  if full_path == '' then
+    return ''
+  end
+  return vim.fn.fnamemodify(full_path, ':t')
+end
+
+--- Get filetype for buffer
+---@param bufnr number Buffer number
+---@return string Filetype
+local function get_filetype(bufnr)
+  return vim.api.nvim_get_option_value('filetype', { buf = bufnr })
+end
+
+--- Build a FIM request from current buffer state
+---@param opts FimRequestOptions Options for building the request
+---@return FimRequest The FIM request object
+function M.build_request(opts)
+  local bufnr = opts.bufnr
+  local row = opts.row
+  local col = opts.col
+  local prefix_lines = opts.prefix_lines
+  local suffix_lines = opts.suffix_lines
+  local format = opts.format or 'infill'
+  local fim_tokens = opts.fim_tokens or DEFAULT_FIM_TOKENS
+
+  -- Get all buffer lines
+  local total_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+  local line_count = #total_lines
+
+  -- Handle empty buffer
+  if line_count == 0 then
+    local metadata = {
+      cursor_line = '',
+      indent = '',
+      filename = get_filename(bufnr),
+      filetype = get_filetype(bufnr),
+    }
+
+    if format == 'fim_tokens' then
+      return {
+        prompt = fim_tokens.prefix .. fim_tokens.suffix .. fim_tokens.middle,
+        metadata = metadata,
+      }
+    end
+
+    return {
+      input_prefix = '',
+      input_suffix = '',
+      metadata = metadata,
+    }
+  end
+
+  -- Clamp row to valid range
+  row = math.max(0, math.min(row, line_count - 1))
+
+  -- Get current line and clamp column
+  local current_line = total_lines[row + 1] or ''
+  col = math.max(0, math.min(col, #current_line))
+
+  -- Calculate prefix line range
+  local prefix_start = math.max(0, row - prefix_lines)
+
+  -- Build prefix: lines before cursor line + partial current line
+  local prefix_parts = {}
+
+  -- Add lines before cursor line
+  for i = prefix_start, row - 1 do
+    local line = total_lines[i + 1]
+    if line then
+      table.insert(prefix_parts, line)
+    end
+  end
+
+  -- Add partial current line (up to cursor position)
+  local current_line_prefix = current_line:sub(1, col)
+
+  -- Join prefix lines with newlines, then add current line prefix
+  local input_prefix
+  if #prefix_parts > 0 then
+    input_prefix = table.concat(prefix_parts, '\n') .. '\n' .. current_line_prefix
+  else
+    input_prefix = current_line_prefix
+  end
+
+  -- Calculate suffix line range
+  local suffix_end = math.min(line_count - 1, row + suffix_lines)
+
+  -- Build suffix: rest of current line + lines after cursor line
+  local suffix_parts = {}
+
+  -- Add rest of current line (from cursor to end)
+  local current_line_suffix = current_line:sub(col + 1)
+  table.insert(suffix_parts, current_line_suffix)
+
+  -- Add lines after cursor line
+  for i = row + 1, suffix_end do
+    local line = total_lines[i + 1]
+    if line then
+      table.insert(suffix_parts, line)
+    end
+  end
+
+  -- Join suffix parts with newlines
+  local input_suffix = table.concat(suffix_parts, '\n')
+
+  -- Build metadata
+  local metadata = {
+    cursor_line = current_line,
+    indent = detect_indent(current_line),
+    filename = get_filename(bufnr),
+    filetype = get_filetype(bufnr),
+  }
+
+  -- Return in requested format
+  if format == 'fim_tokens' then
+    local prompt = fim_tokens.prefix .. input_prefix ..
+                   fim_tokens.suffix .. input_suffix ..
+                   fim_tokens.middle
+    return {
+      prompt = prompt,
+      input_prefix = input_prefix,
+      input_suffix = input_suffix,
+      metadata = metadata,
+    }
+  end
+
+  -- Default: infill format
+  return {
+    input_prefix = input_prefix,
+    input_suffix = input_suffix,
+    metadata = metadata,
+  }
+end
+
+return M
diff --git a/sweep.nvim/lua/sweep/http.lua b/sweep.nvim/lua/sweep/http.lua
new file mode 100644
index 0000000..8877b1b
--- /dev/null
+++ b/sweep.nvim/lua/sweep/http.lua
@@ -0,0 +1,207 @@
+-- sweep.nvim - HTTP client module
+-- Handles async HTTP communication with llama.cpp server
+
+local M = {}
+
+local curl = require('plenary.curl')
+
+-- Default timeout in milliseconds
+local DEFAULT_TIMEOUT = 5000
+
+-- Track active requests for cancellation
+-- Key: request_id, Value: { job = job, cancelled = false }
+local active_requests = {}
+
+-- Counter for generating unique request IDs
+local request_counter = 0
+
+--- Generate a unique request ID
+---@return string
+local function generate_request_id()
+  request_counter = request_counter + 1
+  return string.format('sweep_http_%d_%d', vim.loop.now(), request_counter)
+end
+
+--- Make a completion request to llama.cpp server
+---@param opts table Request options
+---@param opts.endpoint string Server endpoint URL
+---@param opts.body table Request body to be JSON encoded
+---@param opts.timeout? number Request timeout in milliseconds (default: 5000)
+---@param opts.on_success function Callback for successful response: function(response)
+---@param opts.on_error function Callback for error: function(error_message)
+---@return table|nil handle Request handle for cancellation, or nil on error
+function M.request(opts)
+  if not opts or not opts.endpoint or not opts.body then
+    if opts and opts.on_error then
+      opts.on_error('Missing required options: endpoint and body')
+    end
+    return nil
+  end
+
+  local request_id = generate_request_id()
+  local timeout = opts.timeout or DEFAULT_TIMEOUT
+
+  -- Encode body as JSON
+  local body_json
+  local ok, result = pcall(vim.json.encode, opts.body)
+  if not ok then
+    if opts.on_error then
+      opts.on_error('Failed to encode request body as JSON: ' .. tostring(result))
+    end
+    return nil
+  end
+  body_json = result
+
+  -- Create the request entry before making the call
+  active_requests[request_id] = {
+    job = nil,
+    cancelled = false,
+  }
+
+  -- Make the async HTTP request
+  local job = curl.post({
+    url = opts.endpoint,
+    body = body_json,
+    headers = {
+      ['Content-Type'] = 'application/json',
+      ['Accept'] = 'application/json',
+    },
+    timeout = timeout,
+    callback = function(response)
+      -- Check if request was cancelled
+      local request = active_requests[request_id]
+      if not request or request.cancelled then
+        -- Request was cancelled, don't call callbacks
+        active_requests[request_id] = nil
+        return
+      end
+
+      -- Remove from active requests
+      active_requests[request_id] = nil
+
+      -- Handle response
+      if not response then
+        if opts.on_error then
+          vim.schedule(function()
+            opts.on_error('No response received from server')
+          end)
+        end
+        return
+      end
+
+      -- Check for connection errors (status 0 usually indicates connection failure)
+      if response.status == 0 then
+        local error_msg = 'Connection failed'
+        if response.exit then
+          if response.exit == 7 then
+            error_msg = 'Connection refused - is the server running?'
+          elseif response.exit == 28 then
+            error_msg = 'Request timed out'
+          else
+            error_msg = string.format('Connection error (exit code: %d)', response.exit)
+          end
+        end
+        if opts.on_error then
+          vim.schedule(function()
+            opts.on_error(error_msg)
+          end)
+        end
+        return
+      end
+
+      -- Check for HTTP error status
+      if response.status < 200 or response.status >= 300 then
+        if opts.on_error then
+          vim.schedule(function()
+            opts.on_error(string.format('HTTP error %d: %s', response.status, response.body or 'Unknown error'))
+          end)
+        end
+        return
+      end
+
+      -- Parse JSON response
+      local parse_ok, parsed = pcall(vim.json.decode, response.body)
+      if not parse_ok then
+        if opts.on_error then
+          vim.schedule(function()
+            opts.on_error('Failed to parse JSON response: ' .. tostring(parsed))
+          end)
+        end
+        return
+      end
+
+      -- Success!
+      if opts.on_success then
+        vim.schedule(function()
+          opts.on_success(parsed)
+        end)
+      end
+    end,
+  })
+
+  -- Store the job reference
+  if active_requests[request_id] then
+    active_requests[request_id].job = job
+  end
+
+  -- Return handle for cancellation
+  return {
+    id = request_id,
+  }
+end
+
+--- Cancel an in-flight request
+---@param handle table|nil Request handle returned by request()
+function M.cancel(handle)
+  if not handle or not handle.id then
+    return
+  end
+
+  local request = active_requests[handle.id]
+  if not request then
+    return
+  end
+
+  -- Mark as cancelled to prevent callbacks
+  request.cancelled = true
+
+  -- Shutdown the job if it exists
+  if request.job and request.job.shutdown then
+    pcall(function()
+      request.job:shutdown()
+    end)
+  end
+
+  -- Remove from active requests
+  active_requests[handle.id] = nil
+end
+
+--- Cancel all pending requests
+function M.cancel_all()
+  for request_id, request in pairs(active_requests) do
+    -- Mark as cancelled
+    request.cancelled = true
+
+    -- Shutdown the job if it exists
+    if request.job and request.job.shutdown then
+      pcall(function()
+        request.job:shutdown()
+      end)
+    end
+  end
+
+  -- Clear all active requests
+  active_requests = {}
+end
+
+--- Get the number of active requests (useful for debugging)
+---@return number
+function M.get_active_count()
+  local count = 0
+  for _ in pairs(active_requests) do
+    count = count + 1
+  end
+  return count
+end
+
+return M
diff --git a/sweep.nvim/lua/sweep/init.lua b/sweep.nvim/lua/sweep/init.lua
new file mode 100644
index 0000000..dae2447
--- /dev/null
+++ b/sweep.nvim/lua/sweep/init.lua
@@ -0,0 +1,141 @@
+-- sweep.nvim - Main module
+-- AI autocomplete using Sweep's next-edit model with llama.cpp
+
+local M = {}
+
+local config = require('sweep.config')
+
+-- State
+M._enabled = false
+M._initialized = false
+
+--- Initialize all subsystems with current configuration
+local function init_subsystems()
+  local cfg = config.get()
+  local ctx_config = cfg.context or {}
+
+  -- Initialize ring buffer with config
+  local ring = require('sweep.ring')
+  ring.setup({
+    max_chunks = ctx_config.max_ring_chunks or 16,
+    chunk_size = ctx_config.chunk_size or 64,
+  })
+
+  -- Initialize cache
+  local cache = require('sweep.cache')
+  cache.setup({
+    max_entries = 100,
+    ttl_ms = 60000, -- 1 minute TTL
+  })
+end
+
+--- Setup the plugin with user configuration
+---@param opts? table User configuration options
+function M.setup(opts)
+  config.setup(opts)
+  M._initialized = true
+
+  -- Initialize all subsystems
+  init_subsystems()
+
+  if config.options.auto_enable then
+    M.enable()
+  end
+end
+
+--- Enable autocomplete
+function M.enable()
+  if not M._initialized then
+    M.setup()
+  end
+
+  if M._enabled then
+    return
+  end
+
+  M._enabled = true
+
+  -- Enable completion module
+  local completion = require('sweep.completion')
+  completion.enable()
+
+  -- Set up autocmds and keymaps
+  require('sweep.autocmds').setup()
+  require('sweep.keymaps').setup()
+
+  if config.options.show_info then
+    vim.notify('Sweep autocomplete enabled', vim.log.levels.INFO)
+  end
+end
+
+--- Disable autocomplete
+function M.disable()
+  if not M._enabled then
+    return
+  end
+
+  M._enabled = false
+
+  -- Disable completion module (cancels pending requests)
+  local completion = require('sweep.completion')
+  completion.disable()
+
+  -- Tear down autocmds and keymaps
+  require('sweep.autocmds').teardown()
+  require('sweep.keymaps').teardown()
+
+  -- Clear UI
+  require('sweep.ui').clear()
+
+  if config.options.show_info then
+    vim.notify('Sweep autocomplete disabled', vim.log.levels.INFO)
+  end
+end
+
+--- Toggle autocomplete
+function M.toggle()
+  if M._enabled then
+    M.disable()
+  else
+    M.enable()
+  end
+end
+
+--- Check if enabled
+---@return boolean
+function M.is_enabled()
+  return M._enabled
+end
+
+--- Show status
+function M.status()
+  local status = M._enabled and 'enabled' or 'disabled'
+  vim.notify(string.format('Sweep autocomplete: %s', status), vim.log.levels.INFO)
+end
+
+--- Show debug info (using debug pane)
+function M.debug()
+  require('sweep.debug').show_pane()
+end
+
+--- Get debug info as table
+---@return table
+function M.debug_info()
+  return require('sweep.debug').get_info()
+end
+
+--- Clear the completion cache
+function M.clear_cache()
+  local cache = require('sweep.cache')
+  cache.clear()
+  vim.notify('Sweep cache cleared', vim.log.levels.INFO)
+end
+
+--- Clear the ring buffer
+function M.clear_ring()
+  local ring = require('sweep.ring')
+  ring.clear()
+  vim.notify('Sweep ring buffer cleared', vim.log.levels.INFO)
+end
+
+return M
diff --git a/sweep.nvim/lua/sweep/keymaps.lua b/sweep.nvim/lua/sweep/keymaps.lua
new file mode 100644
index 0000000..175a047
--- /dev/null
+++ b/sweep.nvim/lua/sweep/keymaps.lua
@@ -0,0 +1,196 @@
+-- sweep.nvim - Keymaps module for completion interaction
+-- Manages keymaps for accepting, dismissing, and triggering completions
+
+local M = {}
+
+-- Track setup state
+local is_setup_flag = false
+
+-- Track which buffers have keymaps set
+local buffer_keymaps = {}
+
+--- Get the config module
+---@return table
+local function get_config()
+  return require('sweep.config').get()
+end
+
+--- Get the completion module
+---@return table
+local function get_completion()
+  return require('sweep.completion')
+end
+
+--- Get the UI module
+---@return table
+local function get_ui()
+  return require('sweep.ui')
+end
+
+--- Create an expr mapping callback for conditional keymaps
+--- Returns empty string if action taken, otherwise returns the key for passthrough
+---@param key string The key to pass through if completion not visible
+---@param action function The action to take when completion is visible
+---@return function
+local function make_conditional_callback(key, action)
+  return function()
+    if get_ui().is_visible() then
+      action()
+      return ''
+    else
+      -- Pass through the original key
+      return vim.api.nvim_replace_termcodes(key, true, false, true)
+    end
+  end
+end
+
+--- Create a simple callback (no visibility check)
+---@param action function The action to take
+---@return function
+local function make_callback(action)
+  return function()
+    action()
+    return ''
+  end
+end
+
+--- Set up keymaps on the current buffer
+---@param bufnr? number Buffer number (defaults to current buffer)
+local function setup_buffer_keymaps(bufnr)
+  bufnr = bufnr or vim.api.nvim_get_current_buf()
+
+  local cfg = get_config()
+  local km = cfg.keymaps
+
+  -- Track keymaps for this buffer
+  buffer_keymaps[bufnr] = buffer_keymaps[bufnr] or {}
+
+  -- Trigger keymap (no visibility check needed)
+  vim.keymap.set('i', km.trigger, make_callback(function()
+    get_completion().manual_trigger()
+  end), {
+    buffer = bufnr,
+    expr = true,
+    desc = 'Sweep: trigger completion',
+    silent = true,
+  })
+  table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.trigger })
+
+  -- Accept full keymap (only when visible)
+  vim.keymap.set('i', km.accept_full, make_conditional_callback(km.accept_full, function()
+    get_completion().accept_full()
+  end), {
+    buffer = bufnr,
+    expr = true,
+    desc = 'Sweep: accept full completion',
+    silent = true,
+  })
+  table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.accept_full })
+
+  -- Accept line keymap (only when visible)
+  vim.keymap.set('i', km.accept_line, make_conditional_callback(km.accept_line, function()
+    get_completion().accept_line()
+  end), {
+    buffer = bufnr,
+    expr = true,
+    desc = 'Sweep: accept first line',
+    silent = true,
+  })
+  table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.accept_line })
+
+  -- Accept word keymap (only when visible)
+  vim.keymap.set('i', km.accept_word, make_conditional_callback(km.accept_word, function()
+    get_completion().accept_word()
+  end), {
+    buffer = bufnr,
+    expr = true,
+    desc = 'Sweep: accept first word',
+    silent = true,
+  })
+  table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.accept_word })
+
+  -- Dismiss keymap (only when visible)
+  vim.keymap.set('i', km.dismiss, make_conditional_callback(km.dismiss, function()
+    get_completion().dismiss()
+  end), {
+    buffer = bufnr,
+    expr = true,
+    desc = 'Sweep: dismiss completion',
+    silent = true,
+  })
+  table.insert(buffer_keymaps[bufnr], { mode = 'i', lhs = km.dismiss })
+end
+
+--- Remove keymaps from a buffer
+---@param bufnr number Buffer number
+local function teardown_buffer_keymaps(bufnr)
+  if not buffer_keymaps[bufnr] then
+    return
+  end
+
+  if not vim.api.nvim_buf_is_valid(bufnr) then
+    buffer_keymaps[bufnr] = nil
+    return
+  end
+
+  for _, keymap in ipairs(buffer_keymaps[bufnr]) do
+    pcall(vim.keymap.del, keymap.mode, keymap.lhs, { buffer = bufnr })
+  end
+
+  buffer_keymaps[bufnr] = nil
+end
+
+--- Set up keymaps for completion interaction
+--- Called when enabling the plugin
+function M.setup()
+  local bufnr = vim.api.nvim_get_current_buf()
+
+  -- Clear any existing keymaps on this buffer first
+  teardown_buffer_keymaps(bufnr)
+
+  -- Set up new keymaps
+  setup_buffer_keymaps(bufnr)
+
+  is_setup_flag = true
+end
+
+--- Remove keymaps
+--- Called when disabling the plugin
+function M.teardown()
+  -- Remove keymaps from all tracked buffers
+  for bufnr, _ in pairs(buffer_keymaps) do
+    teardown_buffer_keymaps(bufnr)
+  end
+
+  buffer_keymaps = {}
+  is_setup_flag = false
+end
+
+--- Check if keymaps are set up
+---@return boolean
+function M.is_setup()
+  return is_setup_flag
+end
+
+--- Set up keymaps for a specific buffer
+--- Can be called from autocmds when entering a new buffer
+---@param bufnr? number Buffer number (defaults to current buffer)
+function M.setup_buffer(bufnr)
+  bufnr = bufnr or vim.api.nvim_get_current_buf()
+
+  -- Don't set up if already done for this buffer
+  if buffer_keymaps[bufnr] and #buffer_keymaps[bufnr] > 0 then
+    return
+  end
+
+  setup_buffer_keymaps(bufnr)
+end
+
+--- Remove keymaps from a specific buffer
+---@param bufnr? number Buffer number (defaults to current buffer)
+function M.teardown_buffer(bufnr)
+  bufnr = bufnr or vim.api.nvim_get_current_buf()
+  teardown_buffer_keymaps(bufnr)
+end
+
+return M
diff --git a/sweep.nvim/lua/sweep/parser.lua b/sweep.nvim/lua/sweep/parser.lua
new file mode 100644
index 0000000..cf94951
--- /dev/null
+++ b/sweep.nvim/lua/sweep/parser.lua
@@ -0,0 +1,200 @@
+-- Response parser for llama.cpp server completions
+
+local M = {}
+
+--- Split a string by newlines
+---@param str string
+---@return string[]
+local function split_lines(str)
+  if str == '' then
+    return { '' }
+  end
+  local lines = {}
+  for line in str:gmatch('([^\n]*)\n?') do
+    if line ~= '' or #lines == 0 then
+      table.insert(lines, line)
+    end
+  end
+  -- Remove trailing empty string from gmatch if present
+  if #lines > 1 and lines[#lines] == '' then
+    table.remove(lines)
+  end
+  return lines
+end
+
+--- Check if string ends with a given suffix and remove it
+---@param str string
+---@param suffix string
+---@return string, boolean
+local function strip_suffix(str, suffix)
+  if suffix == '' then
+    return str, false
+  end
+  if str:sub(-#suffix) == suffix then
+    return str:sub(1, -#suffix - 1), true
+  end
+  return str, false
+end
+
+--- Trim whitespace from both ends of a string
+---@param str string
+---@return string
+local function trim(str)
+  return str:match('^%s*(.-)%s*$') or str
+end
+
+--- Parse llama.cpp server response and extract completion
+---@param response_body string|table JSON string or already-parsed table from llama.cpp server
+---@param opts table Options: stop_tokens, trim_whitespace, max_lines
+---@return table Parsed result with content, lines, timings, etc.
+function M.parse(response_body, opts)
+  opts = opts or {}
+  local stop_tokens = opts.stop_tokens or {}
+  local trim_whitespace = opts.trim_whitespace or false
+  local max_lines = opts.max_lines
+
+  -- Default empty result structure
+  local result = {
+    content = '',
+    lines = {},
+    tokens_predicted = 0,
+    stopped = false,
+    stop_reason = 'length',
+    timings = {},
+    error = nil,
+  }
+
+  -- Handle both string and table input
+  local data
+  if type(response_body) == 'table' then
+    data = response_body
+  else
+    local ok, parsed = pcall(vim.json.decode, response_body)
+    if not ok or type(parsed) ~= 'table' then
+      result.error = 'Failed to parse JSON response'
+      return result
+    end
+    data = parsed
+  end
+
+  -- Extract content (handle missing content field)
+  if data.content == nil then
+    result.tokens_predicted = data.tokens_predicted or 0
+    result.stopped = data.stop or false
+    result.stop_reason = result.stopped and 'eos' or 'length'
+    return result
+  end
+
+  local content = data.content
+
+  -- Check for stop tokens and strip them
+  local found_stop_token = false
+  for _, token in ipairs(stop_tokens) do
+    local stripped, found = strip_suffix(content, token)
+    if found then
+      content = stripped
+      found_stop_token = true
+      break
+    end
+  end
+
+  -- Trim whitespace if requested
+  if trim_whitespace then
+    content = trim(content)
+  end
+
+  -- Split into lines
+  local lines = split_lines(content)
+
+  -- Apply max_lines limit
+  if max_lines and max_lines > 0 and #lines > max_lines then
+    local limited_lines = {}
+    for i = 1, max_lines do
+      table.insert(limited_lines, lines[i])
+    end
+    lines = limited_lines
+    content = table.concat(lines, '\n')
+  end
+
+  -- Determine stop reason
+  local stopped = data.stop or false
+  local stop_reason
+  if not stopped then
+    stop_reason = 'length'
+  elseif found_stop_token then
+    stop_reason = 'stop_token'
+  else
+    stop_reason = 'eos'
+  end
+
+  -- Extract timings
+  local timings = {}
+  if data.timings then
+    timings.prompt_ms = data.timings.prompt_ms
+    timings.predicted_ms = data.timings.predicted_ms
+
+    -- Calculate tokens per second
+    if data.timings.predicted_ms and data.timings.predicted_n then
+      local predicted_sec = data.timings.predicted_ms / 1000
+      if predicted_sec > 0 then
+        timings.tokens_per_second = math.floor(data.timings.predicted_n / predicted_sec + 0.5)
+      end
+    end
+  end
+
+  result.content = content
+  result.lines = lines
+  result.tokens_predicted = data.tokens_predicted or 0
+  result.stopped = stopped
+  result.stop_reason = stop_reason
+  result.timings = timings
+
+  return result
+end
+
+--- Get the first line from a parsed result
+---@param result table Parsed result from parse()
+---@return string First line of content
+function M.first_line(result)
+  if not result or not result.lines or #result.lines == 0 then
+    return ''
+  end
+  return result.lines[1]
+end
+
+--- Get the first word from a parsed result
+--- Preserves leading whitespace for indentation
+---@param result table Parsed result from parse()
+---@return string First word of content (with leading whitespace)
+function M.first_word(result)
+  if not result or not result.content or result.content == '' then
+    return ''
+  end
+
+  local content = result.content
+
+  -- Find leading whitespace
+  local leading_ws = content:match('^(%s*)') or ''
+
+  -- Find the first word after whitespace
+  local rest = content:sub(#leading_ws + 1)
+
+  -- Word is alphanumeric and underscores
+  local word = rest:match('^([%w_]+)') or ''
+
+  return leading_ws .. word
+end
+
+--- Check if a parsed result is empty (whitespace only)
+---@param result table Parsed result from parse()
+---@return boolean True if content is empty or whitespace-only
+function M.is_empty(result)
+  if not result or not result.content then
+    return true
+  end
+
+  -- Check if content is empty or only whitespace
+  return result.content:match('^%s*$') ~= nil
+end
+
+return M
diff --git a/sweep.nvim/lua/sweep/ring.lua b/sweep.nvim/lua/sweep/ring.lua
new file mode 100644
index 0000000..654e87b
--- /dev/null
+++ b/sweep.nvim/lua/sweep/ring.lua
@@ -0,0 +1,200 @@
+-- sweep.nvim - Ring buffer module for cross-file context collection
+-- Collects chunks from multiple files via yank, buffer enter/leave, and save events
+
+local M = {}
+
+---@class RingChunk
+---@field content string The chunk content
+---@field filename string Full path to the source file
+---@field filetype string The filetype of the source
+---@field source string How the chunk was collected: 'yank', 'buffer_enter', 'buffer_leave', 'save', 'visible'
+---@field timestamp number Unix timestamp when chunk was added
+
+---@class RingConfig
+---@field max_chunks number Maximum number of chunks to store
+---@field chunk_size number Maximum lines per chunk
+
+---@type RingChunk[]
+local buffer = {}
+
+---@type number
+local head = 1
+
+---@type number
+local count = 0
+
+---@type RingConfig
+local config = {
+  max_chunks = 16,
+  chunk_size = 64,
+}
+
+-- Similarity check key length
+local SIMILARITY_PREFIX_LEN = 100
+
+--- Get a fingerprint for duplicate detection (first N chars)
+---@param content string
+---@return string
+local function get_fingerprint(content)
+  return content:sub(1, SIMILARITY_PREFIX_LEN)
+end
+
+--- Check if a chunk with similar content already exists
+---@param content string
+---@return boolean
+local function is_duplicate(content)
+  local fingerprint = get_fingerprint(content)
+  for i = 1, count do
+    local idx = ((head - i - 1) % config.max_chunks) + 1
+    if buffer[idx] and get_fingerprint(buffer[idx].content) == fingerprint then
+      return true
+    end
+  end
+  return false
+end
+
+--- Trim content to max lines
+---@param content string
+---@param max_lines number
+---@return string
+local function trim_to_lines(content, max_lines)
+  local lines = vim.split(content, '\n')
+  if #lines <= max_lines then
+    return content
+  end
+  local trimmed = {}
+  for i = 1, max_lines do
+    trimmed[i] = lines[i]
+  end
+  return table.concat(trimmed, '\n')
+end
+
+--- Initialize the ring buffer with configuration
+---@param opts? RingConfig
+function M.setup(opts)
+  opts = opts or {}
+  config.max_chunks = opts.max_chunks or 16
+  config.chunk_size = opts.chunk_size or 64
+  M.clear()
+end
+
+--- Add a chunk to the ring buffer
+---@param chunk table { content: string, filename: string, filetype: string, source: string }
+function M.add(chunk)
+  if not chunk or not chunk.content or chunk.content == '' then
+    return
+  end
+
+  -- Trim content to chunk_size lines
+  local content = trim_to_lines(chunk.content, config.chunk_size)
+
+  -- Check for duplicates
+  if is_duplicate(content) then
+    return
+  end
+
+  -- Create the full chunk with metadata
+  ---@type RingChunk
+  local ring_chunk = {
+    content = content,
+    filename = chunk.filename or '',
+    filetype = chunk.filetype or '',
+    source = chunk.source or 'unknown',
+    timestamp = os.time(),
+  }
+
+  -- Add to ring buffer
+  buffer[head] = ring_chunk
+  head = (head % config.max_chunks) + 1
+
+  if count < config.max_chunks then
+    count = count + 1
+  end
+end
+
+--- Get all chunks as a formatted context string
+---@return string
+function M.get_context()
+  local chunks = M.get_chunks({})
+  if #chunks == 0 then
+    return ''
+  end
+
+  local parts = {}
+  for _, chunk in ipairs(chunks) do
+    local header = string.format('-- File: %s (%s)', chunk.filename, chunk.filetype)
+    table.insert(parts, header)
+    table.insert(parts, chunk.content)
+    table.insert(parts, '')
+  end
+
+  return table.concat(parts, '\n')
+end
+
+--- Get chunks filtered by criteria
+---@param opts table { filetype?: string, exclude_file?: string, max_chunks?: number }
+---@return RingChunk[]
+function M.get_chunks(opts)
+  opts = opts or {}
+  local result = {}
+
+  -- Iterate from most recent to oldest
+  for i = 1, count do
+    local idx = ((head - i - 1) % config.max_chunks) + 1
+    local chunk = buffer[idx]
+
+    if chunk then
+      local include = true
+
+      -- Filter by filetype
+      if opts.filetype and chunk.filetype ~= opts.filetype then
+        include = false
+      end
+
+      -- Exclude specific file
+      if opts.exclude_file and chunk.filename == opts.exclude_file then
+        include = false
+      end
+
+      if include then
+        table.insert(result, chunk)
+      end
+
+      -- Respect max_chunks limit
+      if opts.max_chunks and #result >= opts.max_chunks then
+        break
+      end
+    end
+  end
+
+  return result
+end
+
+--- Clear the ring buffer
+function M.clear()
+  buffer = {}
+  head = 1
+  count = 0
+end
+
+--- Get statistics about the ring buffer
+---@return table { count: number, max: number, filetypes: table }
+function M.stats()
+  local filetypes = {}
+
+  for i = 1, count do
+    local idx = ((head - i - 1) % config.max_chunks) + 1
+    local chunk = buffer[idx]
+    if chunk and chunk.filetype and chunk.filetype ~= '' then
+      filetypes[chunk.filetype] = (filetypes[chunk.filetype] or 0) + 1
+    end
+  end
+
+  return {
+    count = count,
+    max = config.max_chunks,
+    filetypes = filetypes,
+  }
+end
+
+return M
diff --git a/sweep.nvim/lua/sweep/ui.lua b/sweep.nvim/lua/sweep/ui.lua
new file mode 100644
index 0000000..432ddfb
--- /dev/null
+++ b/sweep.nvim/lua/sweep/ui.lua
@@ -0,0 +1,157 @@
+-- sweep.nvim - UI module for ghost text completion display
+-- Displays completions as ghost text using extmarks
+
+local M = {}
+
+-- Namespace for extmarks
+local ns = vim.api.nvim_create_namespace('sweep')
+
+-- Current completion state
+---@class SweepUIState
+---@field lines string[]
+---@field bufnr number
+---@field row number
+---@field col number
+---@field mark_id number|nil
+local state = {
+  lines = {},
+  bufnr = nil,
+  row = nil,
+  col = nil,
+  mark_id = nil,
+}
+
+--- Clear all internal state
+local function clear_state()
+  state.lines = {}
+  state.bufnr = nil
+  state.row = nil
+  state.col = nil
+  state.mark_id = nil
+end
+
+--- Get the config module
+---@return table
+local function get_config()
+  return require('sweep.config').get()
+end
+
+--- Format info text for display
+---@param info table Info with tokens and latency_ms
+---@return string
+local function format_info(info)
+  local parts = {}
+  if info.tokens then
+    table.insert(parts, string.format('%d tok', info.tokens))
+  end
+  if info.latency_ms then
+    table.insert(parts, string.format('%dms', info.latency_ms))
+  end
+  if #parts > 0 then
+    return ' [' .. table.concat(parts, ', ') .. ']'
+  end
+  return ''
+end
+
+--- Display a completion at the specified position
+---@param opts table Options: lines, bufnr, row, col, info (optional)
+function M.show(opts)
+  local lines = opts.lines or {}
+  local bufnr = opts.bufnr or 0
+  local row = opts.row or 0
+  local col = opts.col or 0
+  local info = opts.info
+
+  -- Resolve buffer 0 to actual buffer number
+  if bufnr == 0 then
+    bufnr = vim.api.nvim_get_current_buf()
+  end
+
+  -- Clear any existing completion first
+  M.clear()
+
+  -- Handle empty lines - don't show anything
+  if #lines == 0 then
+    return
+  end
+
+  local config = get_config()
+  local hl_group = config.ui.hl_group
+  local info_hl_group = config.ui.info_hl_group
+  local show_info = config.ui.show_info
+
+  -- Build virt_text for the first line (inline ghost text)
+  local virt_text = { { lines[1], hl_group } }
+
+  -- Add info to the first line if provided and enabled
+  if info and show_info then
+    local info_text = format_info(info)
+    if info_text ~= '' then
+      table.insert(virt_text, { info_text, info_hl_group })
+    end
+  end
+
+  -- Build virt_lines for additional lines
+  local virt_lines = nil
+  if #lines > 1 then
+    virt_lines = {}
+    for i = 2, #lines do
+      table.insert(virt_lines, { { lines[i], hl_group } })
+    end
+  end
+
+  -- Create the extmark
+  local mark_opts = {
+    virt_text = virt_text,
+    virt_text_pos = 'overlay',
+    hl_mode = 'combine',
+  }
+
+  if virt_lines then
+    mark_opts.virt_lines = virt_lines
+  end
+
+  -- Set the extmark
+  local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, row, col, mark_opts)
+
+  if ok then
+    -- Update state
+    state.lines = lines
+    state.bufnr = bufnr
+    state.row = row
+    state.col = col
+    state.mark_id = mark_id
+  end
+end
+
+--- Clear current completion display
+function M.clear()
+  if state.bufnr and vim.api.nvim_buf_is_valid(state.bufnr) then
+    -- Clear all extmarks in our namespace for the buffer
+    pcall(vim.api.nvim_buf_clear_namespace, state.bufnr, ns, 0, -1)
+  end
+  clear_state()
+end
+
+--- Check if completion is currently visible
+---@return boolean
+function M.is_visible()
+  return state.mark_id ~= nil and state.bufnr ~= nil
+end
+
+--- Get current completion data for acceptance
+---@return table|nil Completion data or nil if not visible
+function M.get_current()
+  if not M.is_visible() then
+    return nil
+  end
+
+  return {
+    lines = state.lines,
+    bufnr = state.bufnr,
+    row = state.row,
+    col = state.col,
+  }
+end
+
+return M
diff --git a/sweep.nvim/plugin/sweep.lua b/sweep.nvim/plugin/sweep.lua
new file mode 100644
index 0000000..cb7757a
--- /dev/null
+++ b/sweep.nvim/plugin/sweep.lua
@@ -0,0 +1,34 @@
+-- sweep.nvim - AI autocomplete using Sweep's next-edit model
+-- Entry point for the plugin
+
+if vim.g.loaded_sweep then
+  return
+end
+vim.g.loaded_sweep = true
+
+-- Require Neovim 0.10+
+if vim.fn.has('nvim-0.10') == 0 then
+  vim.notify('sweep.nvim requires Neovim 0.10 or later', vim.log.levels.ERROR)
+  return
+end
+
+-- Commands
+vim.api.nvim_create_user_command('SweepEnable', function()
+  require('sweep').enable()
+end, { desc = 'Enable Sweep autocomplete' })
+
+vim.api.nvim_create_user_command('SweepDisable', function()
+  require('sweep').disable()
+end, { desc = 'Disable Sweep autocomplete' })
+
+vim.api.nvim_create_user_command('SweepToggle', function()
+  require('sweep').toggle()
+end, { desc = 'Toggle Sweep autocomplete' })
+
+vim.api.nvim_create_user_command('SweepStatus', function()
+  require('sweep').status()
+end, { desc = 'Show Sweep status' })
+
+vim.api.nvim_create_user_command('SweepDebug', function()
+  require('sweep').debug()
+end, { desc = 'Show Sweep debug info' })
diff --git a/sweep.nvim/tests/minimal_init.lua b/sweep.nvim/tests/minimal_init.lua
new file mode 100644
index 0000000..b0cbebf
--- /dev/null
+++ b/sweep.nvim/tests/minimal_init.lua
@@ -0,0 +1,30 @@
+-- Minimal init for running tests with plenary.nvim
+
+-- Add sweep.nvim to runtimepath
+local plugin_root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
+vim.opt.runtimepath:prepend(plugin_root)
+
+-- Add plenary to runtimepath (adjust path as needed)
+local plenary_path = vim.fn.stdpath('data') .. '/lazy/plenary.nvim'
+if vim.fn.isdirectory(plenary_path) == 1 then
+  vim.opt.runtimepath:prepend(plenary_path)
+else
+  -- Try common alternative paths
+  local alt_paths = {
+    vim.fn.expand('~/.local/share/nvim/lazy/plenary.nvim'),
+    vim.fn.expand('~/.local/share/nvim/site/pack/packer/start/plenary.nvim'),
+    '/usr/share/nvim/site/pack/packer/start/plenary.nvim',
+  }
+  for _, path in ipairs(alt_paths) do
+    if vim.fn.isdirectory(path) == 1 then
+      vim.opt.runtimepath:prepend(path)
+      break
+    end
+  end
+end
+
+-- Basic settings for testing
+vim.cmd('runtime plugin/plenary.vim')
+vim.o.swapfile = false
+vim.o.backup = false
+vim.o.writebackup = false
diff --git a/sweep.nvim/tests/sweep/autocmds_spec.lua b/sweep.nvim/tests/sweep/autocmds_spec.lua
new file mode 100644
index 0000000..e6b5daf
--- /dev/null
+++ b/sweep.nvim/tests/sweep/autocmds_spec.lua
@@ -0,0 +1,395 @@
+-- Tests for sweep.autocmds module
+
+describe('sweep.autocmds', function()
+  local autocmds
+  local config
+
+  -- Mock modules
+  local mock_completion
+  local mock_ring
+  local mock_ui
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.autocmds'] = nil
+    package.loaded['sweep.config'] = nil
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.ring'] = nil
+    package.loaded['sweep.ui'] = nil
+
+    -- Setup config with defaults
+    config = require('sweep.config')
+    config.setup()
+
+    -- Create mock modules
+    mock_completion = {
+      trigger_called = false,
+      cancel_called = false,
+      trigger = function()
+        mock_completion.trigger_called = true
+      end,
+      cancel = function()
+        mock_completion.cancel_called = true
+      end,
+    }
+
+    mock_ring = {
+      add_called = false,
+      last_add_args = nil,
+      add = function(args)
+        mock_ring.add_called = true
+        mock_ring.last_add_args = args
+      end,
+    }
+
+    mock_ui = {
+      clear_called = false,
+      clear = function()
+        mock_ui.clear_called = true
+      end,
+    }
+
+    -- Inject mocks
+    package.loaded['sweep.completion'] = mock_completion
+    package.loaded['sweep.ring'] = mock_ring
+    package.loaded['sweep.ui'] = mock_ui
+
+    -- Load autocmds module
+    autocmds = require('sweep.autocmds')
+  end)
+
+  after_each(function()
+    -- Always teardown after tests
+    pcall(function()
+      autocmds.teardown()
+    end)
+  end)
+
+  describe('setup', function()
+    it('should create the sweep augroup', function()
+      autocmds.setup()
+
+      -- Check that the augroup exists by trying to get autocmds in it
+      local ok, result = pcall(vim.api.nvim_get_autocmds, { group = 'sweep' })
+      assert.is_true(ok)
+      assert.is_table(result)
+    end)
+
+    it('should create CursorMovedI autocmd', function()
+      autocmds.setup()
+
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'CursorMovedI' })
+      assert.are.equal(1, #cmds)
+    end)
+
+    it('should create InsertLeave autocmd', function()
+      autocmds.setup()
+
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'InsertLeave' })
+      assert.are.equal(1, #cmds)
+    end)
+
+    it('should create TextYankPost autocmd', function()
+      autocmds.setup()
+
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'TextYankPost' })
+      assert.are.equal(1, #cmds)
+    end)
+
+    it('should create BufEnter autocmd', function()
+      autocmds.setup()
+
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'BufEnter' })
+      assert.are.equal(1, #cmds)
+    end)
+
+    it('should create BufLeave autocmd', function()
+      autocmds.setup()
+
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'BufLeave' })
+      assert.are.equal(1, #cmds)
+    end)
+
+    it('should create BufWritePost autocmd', function()
+      autocmds.setup()
+
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'BufWritePost' })
+      assert.are.equal(1, #cmds)
+    end)
+
+    it('should be idempotent (can be called multiple times safely)', function()
+      autocmds.setup()
+      autocmds.setup()
+      autocmds.setup()
+
+      -- Should still only have one of each
+      local cursor_cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'CursorMovedI' })
+      assert.are.equal(1, #cursor_cmds)
+    end)
+  end)
+
+  describe('teardown', function()
+    it('should remove all autocmds in sweep augroup', function()
+      autocmds.setup()
+
+      -- Verify autocmds exist
+      local cmds_before = vim.api.nvim_get_autocmds({ group = 'sweep' })
+      assert.is_true(#cmds_before > 0)
+
+      autocmds.teardown()
+
+      -- Verify augroup is cleared
+      local cmds_after = vim.api.nvim_get_autocmds({ group = 'sweep' })
+      assert.are.equal(0, #cmds_after)
+    end)
+
+    it('should be safe to call when not set up', function()
+      -- Should not error
+      assert.has_no.errors(function()
+        autocmds.teardown()
+      end)
+    end)
+
+    it('should be safe to call multiple times', function()
+      autocmds.setup()
+
+      assert.has_no.errors(function()
+        autocmds.teardown()
+        autocmds.teardown()
+        autocmds.teardown()
+      end)
+    end)
+  end)
+
+  describe('CursorMovedI behavior', function()
+    local test_bufnr
+
+    before_each(function()
+      -- Create a test buffer
+      test_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, { 'test line' })
+      vim.api.nvim_set_current_buf(test_bufnr)
+      vim.bo[test_bufnr].filetype = 'lua'
+    end)
+
+    after_each(function()
+      if vim.api.nvim_buf_is_valid(test_bufnr) then
+        vim.api.nvim_buf_delete(test_bufnr, { force = true })
+      end
+    end)
+
+    it('should trigger completion on CursorMovedI', function()
+      autocmds.setup()
+
+      -- Simulate CursorMovedI event
+      vim.api.nvim_exec_autocmds('CursorMovedI', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_completion.trigger_called)
+    end)
+
+    it('should not trigger completion for excluded filetypes', function()
+      -- Set filetype to an excluded type
+      vim.bo[test_bufnr].filetype = 'TelescopePrompt'
+
+      autocmds.setup()
+
+      -- Simulate CursorMovedI event
+      vim.api.nvim_exec_autocmds('CursorMovedI', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_false(mock_completion.trigger_called)
+    end)
+  end)
+
+  describe('InsertLeave behavior', function()
+    local test_bufnr
+
+    before_each(function()
+      test_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, { 'test line' })
+      vim.api.nvim_set_current_buf(test_bufnr)
+    end)
+
+    after_each(function()
+      if vim.api.nvim_buf_is_valid(test_bufnr) then
+        vim.api.nvim_buf_delete(test_bufnr, { force = true })
+      end
+    end)
+
+    it('should cancel completion on InsertLeave', function()
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('InsertLeave', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_completion.cancel_called)
+    end)
+
+    it('should clear UI on InsertLeave', function()
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('InsertLeave', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_ui.clear_called)
+    end)
+  end)
+
+  describe('BufLeave behavior during insert', function()
+    local test_bufnr
+
+    before_each(function()
+      test_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, { 'test line' })
+      vim.api.nvim_set_current_buf(test_bufnr)
+    end)
+
+    after_each(function()
+      if vim.api.nvim_buf_is_valid(test_bufnr) then
+        vim.api.nvim_buf_delete(test_bufnr, { force = true })
+      end
+    end)
+
+    it('should cancel completion on BufLeave', function()
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('BufLeave', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_completion.cancel_called)
+    end)
+
+    it('should clear UI on BufLeave', function()
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('BufLeave', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_ui.clear_called)
+    end)
+  end)
+
+  describe('TextYankPost behavior', function()
+    it('should add yanked text to ring buffer', function()
+      autocmds.setup()
+
+      -- Simulate yank event by setting vim.v.event
+      -- Note: We can't easily simulate vim.v.event in tests,
+      -- so we test that the autocmd is created and trust the callback
+      local cmds = vim.api.nvim_get_autocmds({ group = 'sweep', event = 'TextYankPost' })
+      assert.are.equal(1, #cmds)
+
+      -- The callback should be properly configured
+      assert.is_not_nil(cmds[1].callback)
+    end)
+  end)
+
+  describe('BufEnter behavior', function()
+    local test_bufnr
+
+    before_each(function()
+      test_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, {
+        'line 1',
+        'line 2',
+        'line 3',
+      })
+      vim.api.nvim_set_current_buf(test_bufnr)
+      vim.bo[test_bufnr].filetype = 'lua'
+    end)
+
+    after_each(function()
+      if vim.api.nvim_buf_is_valid(test_bufnr) then
+        vim.api.nvim_buf_delete(test_bufnr, { force = true })
+      end
+    end)
+
+    it('should add visible buffer content to ring on BufEnter', function()
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('BufEnter', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_ring.add_called)
+      assert.is_not_nil(mock_ring.last_add_args)
+      assert.are.equal('buffer_enter', mock_ring.last_add_args.source)
+      assert.are.equal('lua', mock_ring.last_add_args.filetype)
+    end)
+  end)
+
+  describe('BufWritePost behavior', function()
+    local test_bufnr
+
+    before_each(function()
+      test_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, {
+        'saved content',
+      })
+      vim.api.nvim_set_current_buf(test_bufnr)
+      vim.bo[test_bufnr].filetype = 'python'
+    end)
+
+    after_each(function()
+      if vim.api.nvim_buf_is_valid(test_bufnr) then
+        vim.api.nvim_buf_delete(test_bufnr, { force = true })
+      end
+    end)
+
+    it('should add visible buffer content to ring on BufWritePost', function()
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('BufWritePost', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_ring.add_called)
+      assert.is_not_nil(mock_ring.last_add_args)
+      assert.are.equal('save', mock_ring.last_add_args.source)
+      assert.are.equal('python', mock_ring.last_add_args.filetype)
+    end)
+  end)
+
+  describe('excluded filetypes', function()
+    local test_bufnr
+
+    before_each(function()
+      test_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, { 'test' })
+      vim.api.nvim_set_current_buf(test_bufnr)
+    end)
+
+    after_each(function()
+      if vim.api.nvim_buf_is_valid(test_bufnr) then
+        vim.api.nvim_buf_delete(test_bufnr, { force = true })
+      end
+    end)
+
+    it('should skip CursorMovedI for help filetype', function()
+      vim.bo[test_bufnr].filetype = 'help'
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('CursorMovedI', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_false(mock_completion.trigger_called)
+    end)
+
+    it('should skip CursorMovedI for TelescopePrompt', function()
+      vim.bo[test_bufnr].filetype = 'TelescopePrompt'
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('CursorMovedI', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_false(mock_completion.trigger_called)
+    end)
+
+    it('should skip CursorMovedI for NvimTree', function()
+      vim.bo[test_bufnr].filetype = 'NvimTree'
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('CursorMovedI', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_false(mock_completion.trigger_called)
+    end)
+
+    it('should allow CursorMovedI for regular filetypes', function()
+      vim.bo[test_bufnr].filetype = 'lua'
+      autocmds.setup()
+
+      vim.api.nvim_exec_autocmds('CursorMovedI', { group = 'sweep', buffer = test_bufnr })
+
+      assert.is_true(mock_completion.trigger_called)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/cache_spec.lua b/sweep.nvim/tests/sweep/cache_spec.lua
new file mode 100644
index 0000000..f2cf9b5
--- /dev/null
+++ b/sweep.nvim/tests/sweep/cache_spec.lua
@@ -0,0 +1,530 @@
+-- Tests for sweep.cache module (LRU cache for completion results)
+
+describe('sweep.cache', function()
+  local cache
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.cache'] = nil
+    cache = require('sweep.cache')
+    cache.setup({
+      max_entries = 5,
+      ttl_ms = 0, -- No expiry by default for most tests
+    })
+  end)
+
+  describe('setup', function()
+    it('should initialize with given options', function()
+      cache.setup({
+        max_entries = 100,
+        ttl_ms = 60000,
+      })
+      local stats = cache.stats()
+      assert.are.equal(0, stats.entries)
+      assert.are.equal(0, stats.hits)
+      assert.are.equal(0, stats.misses)
+      assert.are.equal(0, stats.evictions)
+    end)
+
+    it('should use default options when none provided', function()
+      package.loaded['sweep.cache'] = nil
+      cache = require('sweep.cache')
+      cache.setup()
+      local stats = cache.stats()
+      assert.are.equal(0, stats.entries)
+    end)
+  end)
+
+  describe('set and get', function()
+    it('should store value retrievable by get', function()
+      cache.set('key1', { completion = 'hello world' })
+      local value = cache.get('key1')
+      assert.is_not_nil(value)
+      assert.are.equal('hello world', value.completion)
+    end)
+
+    it('should return nil for missing key', function()
+      local value = cache.get('nonexistent')
+      assert.is_nil(value)
+    end)
+
+    it('should overwrite existing key with new value', function()
+      cache.set('key1', { value = 'first' })
+      cache.set('key1', { value = 'second' })
+      local value = cache.get('key1')
+      assert.are.equal('second', value.value)
+    end)
+
+    it('should store multiple different keys', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+
+      assert.are.equal('value1', cache.get('key1'))
+      assert.are.equal('value2', cache.get('key2'))
+      assert.are.equal('value3', cache.get('key3'))
+    end)
+  end)
+
+  describe('LRU eviction', function()
+    it('should evict least recently used entry when max_entries exceeded', function()
+      -- max_entries is 5
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+      cache.set('key4', 'value4')
+      cache.set('key5', 'value5')
+
+      -- Cache is now full, add one more
+      cache.set('key6', 'value6')
+
+      -- key1 should be evicted (least recently used)
+      assert.is_nil(cache.get('key1'))
+      -- key6 should exist
+      assert.are.equal('value6', cache.get('key6'))
+
+      local stats = cache.stats()
+      assert.are.equal(5, stats.entries)
+      assert.are.equal(1, stats.evictions)
+    end)
+
+    it('should update recency on get', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+      cache.set('key4', 'value4')
+      cache.set('key5', 'value5')
+
+      -- Access key1 to make it recently used
+      cache.get('key1')
+
+      -- Add new entries to trigger evictions
+      cache.set('key6', 'value6')
+
+      -- key1 should still exist (was recently accessed)
+      assert.is_not_nil(cache.get('key1'))
+      -- key2 should be evicted (was least recently used)
+      assert.is_nil(cache.get('key2'))
+    end)
+
+    it('should update recency on set of existing key', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+      cache.set('key4', 'value4')
+      cache.set('key5', 'value5')
+
+      -- Update key1 to make it recently used
+      cache.set('key1', 'updated')
+
+      -- Add new entry to trigger eviction
+      cache.set('key6', 'value6')
+
+      -- key1 should still exist (was recently updated)
+      assert.are.equal('updated', cache.get('key1'))
+      -- key2 should be evicted
+      assert.is_nil(cache.get('key2'))
+    end)
+
+    it('should track multiple evictions', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+      cache.set('key4', 'value4')
+      cache.set('key5', 'value5')
+
+      -- Add 3 more entries
+      cache.set('key6', 'value6')
+      cache.set('key7', 'value7')
+      cache.set('key8', 'value8')
+
+      local stats = cache.stats()
+      assert.are.equal(3, stats.evictions)
+    end)
+  end)
+
+  describe('TTL expiration', function()
+    it('should return nil for expired entry', function()
+      cache.setup({
+        max_entries = 100,
+        ttl_ms = 50, -- 50ms TTL
+      })
+
+      cache.set('key1', 'value1')
+
+      -- Wait for expiration
+      vim.wait(100, function() return false end)
+
+      -- Should be expired
+      local value = cache.get('key1')
+      assert.is_nil(value)
+    end)
+
+    it('should return value for non-expired entry', function()
+      cache.setup({
+        max_entries = 100,
+        ttl_ms = 5000, -- 5 second TTL
+      })
+
+      cache.set('key1', 'value1')
+      local value = cache.get('key1')
+      assert.are.equal('value1', value)
+    end)
+
+    it('should not expire when ttl_ms is 0', function()
+      cache.setup({
+        max_entries = 100,
+        ttl_ms = 0, -- No expiry
+      })
+
+      cache.set('key1', 'value1')
+
+      -- Wait a bit
+      vim.wait(50, function() return false end)
+
+      -- Should still be available
+      local value = cache.get('key1')
+      assert.are.equal('value1', value)
+    end)
+
+    it('should count expired entries as misses', function()
+      cache.setup({
+        max_entries = 100,
+        ttl_ms = 50,
+      })
+
+      cache.set('key1', 'value1')
+      cache.get('key1') -- hit
+
+      vim.wait(100, function() return false end)
+
+      cache.get('key1') -- miss (expired)
+
+      local stats = cache.stats()
+      assert.are.equal(1, stats.hits)
+      assert.are.equal(1, stats.misses)
+    end)
+  end)
+
+  describe('has', function()
+    it('should return true for existing key', function()
+      cache.set('key1', 'value1')
+      assert.is_true(cache.has('key1'))
+    end)
+
+    it('should return false for missing key', function()
+      assert.is_false(cache.has('nonexistent'))
+    end)
+
+    it('should return false for expired key', function()
+      cache.setup({
+        max_entries = 100,
+        ttl_ms = 50,
+      })
+
+      cache.set('key1', 'value1')
+
+      vim.wait(100, function() return false end)
+
+      assert.is_false(cache.has('key1'))
+    end)
+
+    it('should not update recency', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+      cache.set('key4', 'value4')
+      cache.set('key5', 'value5')
+
+      -- Check key1 with has (should NOT update recency)
+      cache.has('key1')
+
+      -- Add new entry
+      cache.set('key6', 'value6')
+
+      -- key1 should still be evicted (has() didn't update recency)
+      assert.is_false(cache.has('key1'))
+    end)
+  end)
+
+  describe('remove', function()
+    it('should delete specific entry', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+
+      cache.remove('key1')
+
+      assert.is_nil(cache.get('key1'))
+      assert.are.equal('value2', cache.get('key2'))
+    end)
+
+    it('should handle removing non-existent key gracefully', function()
+      cache.remove('nonexistent')
+      -- Should not error
+      assert.is_true(true)
+    end)
+
+    it('should update entry count', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+
+      cache.remove('key1')
+
+      local stats = cache.stats()
+      assert.are.equal(1, stats.entries)
+    end)
+  end)
+
+  describe('clear', function()
+    it('should remove all entries', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+      cache.set('key3', 'value3')
+
+      cache.clear()
+
+      assert.is_nil(cache.get('key1'))
+      assert.is_nil(cache.get('key2'))
+      assert.is_nil(cache.get('key3'))
+    end)
+
+    it('should reset entry count', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+
+      cache.clear()
+
+      local stats = cache.stats()
+      assert.are.equal(0, stats.entries)
+    end)
+
+    it('should preserve stats counters', function()
+      cache.set('key1', 'value1')
+      cache.get('key1') -- hit
+      cache.get('missing') -- miss
+
+      cache.clear()
+
+      local stats = cache.stats()
+      assert.are.equal(0, stats.entries)
+      -- Stats should still reflect historical data
+      assert.are.equal(1, stats.hits)
+      assert.are.equal(1, stats.misses)
+    end)
+  end)
+
+  describe('stats', function()
+    it('should track hits correctly', function()
+      cache.set('key1', 'value1')
+      cache.get('key1')
+      cache.get('key1')
+      cache.get('key1')
+
+      local stats = cache.stats()
+      assert.are.equal(3, stats.hits)
+    end)
+
+    it('should track misses correctly', function()
+      cache.get('missing1')
+      cache.get('missing2')
+
+      local stats = cache.stats()
+      assert.are.equal(2, stats.misses)
+    end)
+
+    it('should track evictions correctly', function()
+      -- Fill cache
+      for i = 1, 5 do
+        cache.set('key' .. i, 'value' .. i)
+      end
+      -- Trigger evictions
+      for i = 6, 10 do
+        cache.set('key' .. i, 'value' .. i)
+      end
+
+      local stats = cache.stats()
+      assert.are.equal(5, stats.evictions)
+    end)
+
+    it('should return current entry count', function()
+      cache.set('key1', 'value1')
+      cache.set('key2', 'value2')
+
+      local stats = cache.stats()
+      assert.are.equal(2, stats.entries)
+    end)
+  end)
+
+  describe('make_key', function()
+    it('should generate consistent keys for same input', function()
+      local key1 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file.lua',
+      })
+      local key2 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file.lua',
+      })
+
+      assert.are.equal(key1, key2)
+    end)
+
+    it('should generate different keys for different prefix', function()
+      local key1 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file.lua',
+      })
+      local key2 = cache.make_key({
+        prefix = 'local y = ',
+        suffix = '\nend',
+        filename = '/path/to/file.lua',
+      })
+
+      assert.are_not.equal(key1, key2)
+    end)
+
+    it('should generate different keys for different suffix', function()
+      local key1 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file.lua',
+      })
+      local key2 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nreturn x',
+        filename = '/path/to/file.lua',
+      })
+
+      assert.are_not.equal(key1, key2)
+    end)
+
+    it('should generate different keys for different filename', function()
+      local key1 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file1.lua',
+      })
+      local key2 = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file2.lua',
+      })
+
+      assert.are_not.equal(key1, key2)
+    end)
+
+    it('should truncate long prefix to last N characters', function()
+      local long_prefix = string.rep('a', 200)
+      local key1 = cache.make_key({
+        prefix = long_prefix,
+        suffix = 'end',
+        filename = '/file.lua',
+      })
+      local key2 = cache.make_key({
+        prefix = 'different' .. long_prefix:sub(-100),
+        suffix = 'end',
+        filename = '/file.lua',
+      })
+
+      -- Keys should be equal because only last 100 chars matter
+      assert.are.equal(key1, key2)
+    end)
+
+    it('should truncate long suffix to first N characters', function()
+      local long_suffix = string.rep('b', 100)
+      local key1 = cache.make_key({
+        prefix = 'start',
+        suffix = long_suffix,
+        filename = '/file.lua',
+      })
+      local key2 = cache.make_key({
+        prefix = 'start',
+        suffix = long_suffix:sub(1, 50) .. 'different',
+        filename = '/file.lua',
+      })
+
+      -- Keys should be equal because only first 50 chars matter
+      assert.are.equal(key1, key2)
+    end)
+
+    it('should handle empty prefix and suffix', function()
+      local key = cache.make_key({
+        prefix = '',
+        suffix = '',
+        filename = '/file.lua',
+      })
+
+      assert.is_not_nil(key)
+      assert.is_true(#key > 0)
+    end)
+
+    it('should handle nil filename', function()
+      local key = cache.make_key({
+        prefix = 'code',
+        suffix = 'more',
+        filename = nil,
+      })
+
+      assert.is_not_nil(key)
+    end)
+
+    it('should return a string', function()
+      local key = cache.make_key({
+        prefix = 'local x = ',
+        suffix = '\nend',
+        filename = '/path/to/file.lua',
+      })
+
+      assert.are.equal('string', type(key))
+    end)
+  end)
+
+  describe('edge cases', function()
+    it('should handle very large values', function()
+      local large_value = {
+        completion = string.rep('x', 10000),
+        tokens = {},
+      }
+      for i = 1, 1000 do
+        large_value.tokens[i] = i
+      end
+
+      cache.set('large', large_value)
+      local retrieved = cache.get('large')
+
+      assert.are.equal(10000, #retrieved.completion)
+      assert.are.equal(1000, #retrieved.tokens)
+    end)
+
+    it('should handle special characters in keys', function()
+      local special_key = 'key\nwith\ttabs\rand\0null'
+      cache.set(special_key, 'value')
+      assert.are.equal('value', cache.get(special_key))
+    end)
+
+    it('should handle nil values', function()
+      -- Setting nil should be equivalent to remove
+      cache.set('key1', 'value1')
+      cache.set('key1', nil)
+      assert.is_nil(cache.get('key1'))
+    end)
+
+    it('should work after multiple setup calls', function()
+      cache.set('key1', 'value1')
+
+      cache.setup({
+        max_entries = 10,
+        ttl_ms = 0,
+      })
+
+      -- After setup, cache should be cleared
+      assert.is_nil(cache.get('key1'))
+
+      cache.set('key2', 'value2')
+      assert.are.equal('value2', cache.get('key2'))
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/completion_spec.lua b/sweep.nvim/tests/sweep/completion_spec.lua
new file mode 100644
index 0000000..c44461b
--- /dev/null
+++ b/sweep.nvim/tests/sweep/completion_spec.lua
@@ -0,0 +1,850 @@
+-- Tests for sweep.completion module
+
+describe('sweep.completion', function()
+  local completion
+  local config
+  local mock_http
+  local mock_fim
+  local mock_parser
+  local mock_ui
+  local test_bufnr
+
+  -- Track timer handles for cleanup
+  local created_timers = {}
+
+  -- Mock timer implementation
+  local mock_timer = {
+    start = function(self, timeout, repeat_interval, callback)
+      self._callback = callback
+      self._timeout = timeout
+      self._started = true
+    end,
+    stop = function(self)
+      self._started = false
+      self._callback = nil
+    end,
+    close = function(self)
+      self._started = false
+      self._callback = nil
+      self._closed = true
+    end,
+    is_active = function(self)
+      return self._started or false
+    end,
+  }
+
+  local function create_mock_timer()
+    local timer = vim.deepcopy(mock_timer)
+    timer._started = false
+    timer._callback = nil
+    timer._closed = false
+    table.insert(created_timers, timer)
+    return timer
+  end
+
+  -- Original vim.loop.new_timer
+  local original_new_timer
+
+  -- Additional mocks for cache, context, and ring
+  local mock_cache
+  local mock_context
+  local mock_ring
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.config'] = nil
+    package.loaded['sweep.http'] = nil
+    package.loaded['sweep.fim'] = nil
+    package.loaded['sweep.parser'] = nil
+    package.loaded['sweep.ui'] = nil
+    package.loaded['sweep.cache'] = nil
+    package.loaded['sweep.context'] = nil
+    package.loaded['sweep.ring'] = nil
+
+    -- Setup config
+    config = require('sweep.config')
+    config.setup({
+      debounce_ms = 100,
+      filetypes_exclude = { 'help', 'TelescopePrompt' },
+      context = {
+        use_lsp = false,
+        use_treesitter = false,
+      },
+    })
+
+    -- Create mock http module
+    mock_http = {
+      _last_request = nil,
+      _request_count = 0,
+      _current_handle = nil,
+      request = function(opts)
+        mock_http._last_request = opts
+        mock_http._request_count = mock_http._request_count + 1
+        mock_http._current_handle = { id = 'test-handle-' .. mock_http._request_count }
+        return mock_http._current_handle
+      end,
+      cancel = function(handle)
+        mock_http._cancelled_handle = handle
+      end,
+      cancel_all = function()
+        mock_http._all_cancelled = true
+      end,
+    }
+
+    -- Create mock fim module
+    mock_fim = {
+      _last_request = nil,
+      build_request = function(opts)
+        mock_fim._last_request = opts
+        return {
+          input_prefix = 'prefix_code',
+          input_suffix = 'suffix_code',
+          metadata = {
+            cursor_line = 'current line',
+            indent = '  ',
+            filename = 'test.lua',
+            filetype = 'lua',
+          },
+        }
+      end,
+    }
+
+    -- Create mock parser module
+    mock_parser = {
+      parse = function(response_body, opts)
+        return {
+          content = 'completed code here',
+          lines = { 'completed code here' },
+          tokens_predicted = 10,
+          stopped = true,
+          stop_reason = 'eos',
+          timings = { predicted_ms = 50 },
+        }
+      end,
+      first_line = function(result)
+        if result and result.lines and #result.lines > 0 then
+          return result.lines[1]
+        end
+        return ''
+      end,
+      first_word = function(result)
+        if result and result.content then
+          return result.content:match('^%s*%S+') or ''
+        end
+        return ''
+      end,
+      is_empty = function(result)
+        return not result or not result.content or result.content:match('^%s*$') ~= nil
+      end,
+    }
+
+    -- Create mock ui module
+    mock_ui = {
+      _shown = nil,
+      _cleared = false,
+      _visible = false,
+      _current = nil,
+      show = function(opts)
+        mock_ui._shown = opts
+        mock_ui._visible = true
+        mock_ui._current = {
+          lines = opts.lines,
+          bufnr = opts.bufnr,
+          row = opts.row,
+          col = opts.col,
+        }
+      end,
+      clear = function()
+        mock_ui._cleared = true
+        mock_ui._visible = false
+        mock_ui._current = nil
+      end,
+      is_visible = function()
+        return mock_ui._visible
+      end,
+      get_current = function()
+        return mock_ui._current
+      end,
+    }
+
+    -- Create mock cache module
+    mock_cache = {
+      _entries = {},
+      _hits = 0,
+      _misses = 0,
+      _last_key = nil,
+      _last_value = nil,
+      setup = function(opts) end,
+      get = function(key)
+        mock_cache._last_key = key
+        if mock_cache._entries[key] then
+          mock_cache._hits = mock_cache._hits + 1
+          return mock_cache._entries[key]
+        end
+        mock_cache._misses = mock_cache._misses + 1
+        return nil
+      end,
+      set = function(key, value)
+        mock_cache._last_key = key
+        mock_cache._last_value = value
+        mock_cache._entries[key] = value
+      end,
+      make_key = function(opts)
+        return (opts.filename or '') .. '|' .. (opts.prefix or ''):sub(-50) .. '|' .. (opts.suffix or ''):sub(1, 25)
+      end,
+      stats = function()
+        local count = 0
+        for _ in pairs(mock_cache._entries) do count = count + 1 end
+        return { entries = count, hits = mock_cache._hits, misses = mock_cache._misses }
+      end,
+      clear = function()
+        mock_cache._entries = {}
+      end,
+    }
+
+    -- Create mock context module
+    mock_context = {
+      get = function(opts)
+        return {
+          definitions = {},
+          scope = nil,
+          imports = {},
+          formatted = '',
+        }
+      end,
+    }
+
+    -- Create mock ring module
+    mock_ring = {
+      _chunks = {},
+      setup = function(opts) end,
+      add = function(chunk) table.insert(mock_ring._chunks, chunk) end,
+      get_context = function() return '' end,
+      get_chunks = function(opts) return mock_ring._chunks end,
+      stats = function() return { count = #mock_ring._chunks, max = 16, filetypes = {} } end,
+      clear = function() mock_ring._chunks = {} end,
+    }
+
+    -- Inject mocks into package.loaded before requiring completion
+    package.loaded['sweep.http'] = mock_http
+    package.loaded['sweep.fim'] = mock_fim
+    package.loaded['sweep.parser'] = mock_parser
+    package.loaded['sweep.ui'] = mock_ui
+    package.loaded['sweep.cache'] = mock_cache
+    package.loaded['sweep.context'] = mock_context
+    package.loaded['sweep.ring'] = mock_ring
+
+    -- Mock vim.loop.new_timer
+    created_timers = {}
+    original_new_timer = vim.loop.new_timer
+    vim.loop.new_timer = create_mock_timer
+
+    -- Create a test buffer
+    test_bufnr = vim.api.nvim_create_buf(false, true)
+    vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, {
+      'function hello()',
+      '  print("world")',
+      'end',
+    })
+    vim.api.nvim_set_current_buf(test_bufnr)
+    vim.api.nvim_set_option_value('filetype', 'lua', { buf = test_bufnr })
+
+    -- Position cursor
+    vim.api.nvim_win_set_cursor(0, { 2, 8 })
+
+    completion = require('sweep.completion')
+  end)
+
+  after_each(function()
+    -- Restore original vim.loop.new_timer
+    vim.loop.new_timer = original_new_timer
+
+    -- Clean up timers
+    for _, timer in ipairs(created_timers) do
+      if timer.is_active and timer:is_active() then
+        pcall(function() timer:stop() end)
+      end
+    end
+    created_timers = {}
+
+    -- Clean up test buffer
+    if vim.api.nvim_buf_is_valid(test_bufnr) then
+      vim.api.nvim_buf_delete(test_bufnr, { force = true })
+    end
+
+    -- Reset package.loaded
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.http'] = nil
+    package.loaded['sweep.fim'] = nil
+    package.loaded['sweep.parser'] = nil
+    package.loaded['sweep.ui'] = nil
+    package.loaded['sweep.cache'] = nil
+    package.loaded['sweep.context'] = nil
+    package.loaded['sweep.ring'] = nil
+  end)
+
+  describe('trigger', function()
+    it('should start debounce timer', function()
+      completion.trigger()
+
+      -- Should have created a timer
+      assert.is_true(#created_timers >= 1)
+      local timer = created_timers[#created_timers]
+      assert.is_true(timer._started)
+      assert.are.equal(100, timer._timeout) -- debounce_ms from config
+    end)
+
+    it('should cancel existing timer on new trigger', function()
+      completion.trigger()
+      local first_timer = created_timers[#created_timers]
+
+      completion.trigger()
+      local second_timer = created_timers[#created_timers]
+
+      -- First timer should be stopped
+      assert.is_false(first_timer._started)
+      -- Second timer should be active
+      assert.is_true(second_timer._started)
+    end)
+
+    it('should not trigger for excluded filetypes', function()
+      vim.api.nvim_set_option_value('filetype', 'help', { buf = test_bufnr })
+
+      completion.trigger()
+
+      -- No timer should be started for excluded filetype
+      local timer_started = false
+      for _, timer in ipairs(created_timers) do
+        if timer._started then
+          timer_started = true
+        end
+      end
+      assert.is_false(timer_started)
+    end)
+
+    it('should not trigger for TelescopePrompt filetype', function()
+      vim.api.nvim_set_option_value('filetype', 'TelescopePrompt', { buf = test_bufnr })
+
+      completion.trigger()
+
+      local timer_started = false
+      for _, timer in ipairs(created_timers) do
+        if timer._started then
+          timer_started = true
+        end
+      end
+      assert.is_false(timer_started)
+    end)
+
+    it('should make HTTP request after debounce fires', function()
+      completion.trigger()
+
+      -- Simulate timer callback firing
+      local timer = created_timers[#created_timers]
+      assert.is_not_nil(timer._callback)
+      timer._callback()
+
+      -- Should have made a request
+      assert.are.equal(1, mock_http._request_count)
+      assert.is_not_nil(mock_http._last_request)
+    end)
+
+    it('should build FIM request with correct buffer context', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- FIM should have been called with current buffer info
+      assert.is_not_nil(mock_fim._last_request)
+      assert.are.equal(test_bufnr, mock_fim._last_request.bufnr)
+    end)
+
+    it('should include proper request body for llama.cpp', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Check request body format
+      local body = mock_http._last_request.body
+      assert.is_not_nil(body)
+      assert.is_not_nil(body.input_prefix)
+      assert.is_not_nil(body.input_suffix)
+      assert.is_not_nil(body.n_predict)
+      assert.is_not_nil(body.temperature)
+      assert.is_not_nil(body.cache_prompt)
+      assert.is_not_nil(body.stop)
+    end)
+  end)
+
+  describe('multiple rapid triggers', function()
+    it('should only result in one request after debounce', function()
+      -- Trigger multiple times rapidly
+      completion.trigger()
+      completion.trigger()
+      completion.trigger()
+      completion.trigger()
+      completion.trigger()
+
+      -- Only the last timer should be active
+      local active_count = 0
+      local last_active_timer = nil
+      for _, timer in ipairs(created_timers) do
+        if timer._started then
+          active_count = active_count + 1
+          last_active_timer = timer
+        end
+      end
+      assert.are.equal(1, active_count)
+
+      -- Fire the timer
+      last_active_timer._callback()
+
+      -- Should only have made one HTTP request
+      assert.are.equal(1, mock_http._request_count)
+    end)
+  end)
+
+  describe('cancel', function()
+    it('should stop pending debounce timer', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      assert.is_true(timer._started)
+
+      completion.cancel()
+
+      assert.is_false(timer._started)
+    end)
+
+    it('should cancel in-flight HTTP request', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback() -- Fire timer to make HTTP request
+
+      completion.cancel()
+
+      -- HTTP cancel should have been called
+      assert.is_not_nil(mock_http._cancelled_handle)
+    end)
+
+    it('should clear UI on cancel', function()
+      -- Show something first
+      mock_ui._visible = true
+
+      completion.cancel()
+
+      assert.is_true(mock_ui._cleared)
+    end)
+  end)
+
+  describe('successful completion', function()
+    it('should show completion in UI on successful response', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Simulate successful HTTP response
+      local on_success = mock_http._last_request.on_success
+      assert.is_not_nil(on_success)
+
+      on_success({ content = 'completed code here', tokens_predicted = 10 })
+
+      -- UI should have been called with completion
+      assert.is_not_nil(mock_ui._shown)
+      assert.is_not_nil(mock_ui._shown.lines)
+    end)
+
+    it('should include timing info in UI display', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      local on_success = mock_http._last_request.on_success
+      on_success({ content = 'code', tokens_predicted = 10, timings = { predicted_ms = 100 } })
+
+      assert.is_not_nil(mock_ui._shown.info)
+    end)
+  end)
+
+  describe('request error handling', function()
+    it('should not crash on error response', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      local on_error = mock_http._last_request.on_error
+      assert.is_not_nil(on_error)
+
+      -- Should not throw
+      assert.has_no.errors(function()
+        on_error('Connection refused')
+      end)
+    end)
+
+    it('should clear UI on error', function()
+      -- First show something
+      mock_ui._visible = true
+      mock_ui._current = { lines = { 'old completion' } }
+
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      local on_error = mock_http._last_request.on_error
+      on_error('Server error')
+
+      assert.is_true(mock_ui._cleared)
+    end)
+  end)
+
+  describe('manual_trigger', function()
+    it('should trigger completion immediately without debounce', function()
+      completion.manual_trigger()
+
+      -- Should make request immediately
+      assert.are.equal(1, mock_http._request_count)
+    end)
+
+    it('should still check for excluded filetypes', function()
+      vim.api.nvim_set_option_value('filetype', 'help', { buf = test_bufnr })
+
+      completion.manual_trigger()
+
+      assert.are.equal(0, mock_http._request_count)
+    end)
+  end)
+
+  describe('accept_full', function()
+    it('should insert full completion text', function()
+      -- Setup UI with a completion
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'line one', 'line two', 'line three' },
+        bufnr = test_bufnr,
+        row = 1, -- 0-indexed
+        col = 8,
+      }
+
+      completion.accept_full()
+
+      -- UI should be cleared after accept
+      assert.is_true(mock_ui._cleared)
+    end)
+
+    it('should do nothing if no completion visible', function()
+      mock_ui._visible = false
+      mock_ui._current = nil
+
+      -- Should not throw
+      assert.has_no.errors(function()
+        completion.accept_full()
+      end)
+    end)
+  end)
+
+  describe('accept_line', function()
+    it('should insert only first line of completion', function()
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'first line', 'second line' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 8,
+      }
+
+      completion.accept_line()
+
+      assert.is_true(mock_ui._cleared)
+    end)
+
+    it('should handle single-line completion', function()
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'only line' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 8,
+      }
+
+      assert.has_no.errors(function()
+        completion.accept_line()
+      end)
+    end)
+  end)
+
+  describe('accept_word', function()
+    it('should insert only first word of completion', function()
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'first second third' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 8,
+      }
+
+      completion.accept_word()
+
+      assert.is_true(mock_ui._cleared)
+    end)
+
+    it('should preserve leading whitespace with first word', function()
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { '  indented word' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 8,
+      }
+
+      assert.has_no.errors(function()
+        completion.accept_word()
+      end)
+    end)
+  end)
+
+  describe('dismiss', function()
+    it('should clear UI without inserting text', function()
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'some completion' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 8,
+      }
+
+      -- Get original buffer content
+      local original_lines = vim.api.nvim_buf_get_lines(test_bufnr, 0, -1, false)
+
+      completion.dismiss()
+
+      -- UI should be cleared
+      assert.is_true(mock_ui._cleared)
+
+      -- Buffer content should be unchanged
+      local new_lines = vim.api.nvim_buf_get_lines(test_bufnr, 0, -1, false)
+      assert.are.same(original_lines, new_lines)
+    end)
+
+    it('should cancel any pending request', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback() -- Start HTTP request
+
+      completion.dismiss()
+
+      -- Should have cancelled the request
+      assert.is_not_nil(mock_http._cancelled_handle)
+    end)
+
+    it('should be safe to call when no completion visible', function()
+      mock_ui._visible = false
+
+      assert.has_no.errors(function()
+        completion.dismiss()
+      end)
+    end)
+  end)
+
+  describe('state management', function()
+    it('should cancel previous request when new one starts', function()
+      completion.trigger()
+      local timer1 = created_timers[#created_timers]
+      timer1._callback() -- First request
+
+      local first_handle = mock_http._current_handle
+
+      completion.trigger()
+      local timer2 = created_timers[#created_timers]
+      timer2._callback() -- Second request
+
+      -- First request should have been cancelled
+      assert.are.same(first_handle, mock_http._cancelled_handle)
+    end)
+
+    it('should track current request handle for cancellation', function()
+      completion.trigger()
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Get the handle
+      local handle = mock_http._current_handle
+
+      completion.cancel()
+
+      assert.are.same(handle, mock_http._cancelled_handle)
+    end)
+  end)
+
+  describe('text insertion', function()
+    it('should insert text at correct cursor position for accept_full', function()
+      -- Position cursor at specific location
+      vim.api.nvim_win_set_cursor(0, { 2, 8 }) -- Line 2, col 8 (after 'print("')
+
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'inserted' },
+        bufnr = test_bufnr,
+        row = 1, -- 0-indexed row
+        col = 8,
+      }
+
+      completion.accept_full()
+
+      -- Check that text was inserted
+      local lines = vim.api.nvim_buf_get_lines(test_bufnr, 0, -1, false)
+      -- The second line should have the inserted text
+      assert.is_true(lines[2]:find('inserted') ~= nil)
+    end)
+
+    it('should handle multi-line insertion for accept_full', function()
+      vim.api.nvim_win_set_cursor(0, { 2, 8 })
+
+      mock_ui._visible = true
+      mock_ui._current = {
+        lines = { 'line1', 'line2', 'line3' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 8,
+      }
+
+      completion.accept_full()
+
+      local lines = vim.api.nvim_buf_get_lines(test_bufnr, 0, -1, false)
+      -- Should have more lines now
+      assert.is_true(#lines >= 4)
+    end)
+  end)
+
+  describe('cache integration', function()
+    it('should check cache before making HTTP request', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Cache get should have been called
+      assert.is_not_nil(mock_cache._last_key)
+    end)
+
+    it('should use cached result and skip HTTP request on cache hit', function()
+      -- Pre-populate cache with a result
+      local cache_key = mock_cache.make_key({
+        prefix = 'prefix_code',
+        suffix = 'suffix_code',
+        filename = '',
+      })
+      mock_cache._entries[cache_key] = {
+        lines = { 'cached completion' },
+        tokens_predicted = 5,
+      }
+
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Should have used cache (no HTTP request made)
+      assert.are.equal(0, mock_http._request_count)
+
+      -- UI should have been updated with cached result
+      assert.is_not_nil(mock_ui._shown)
+      assert.are.same({ 'cached completion' }, mock_ui._shown.lines)
+    end)
+
+    it('should store successful completion in cache', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Simulate successful HTTP response
+      local on_success = mock_http._last_request.on_success
+      on_success({ content = 'new completion', tokens_predicted = 10 })
+
+      -- Cache should have been updated
+      assert.is_not_nil(mock_cache._last_value)
+      assert.is_not_nil(mock_cache._last_value.lines)
+    end)
+
+    it('should show latency of 0 for cached results', function()
+      -- Pre-populate cache
+      local cache_key = mock_cache.make_key({
+        prefix = 'prefix_code',
+        suffix = 'suffix_code',
+        filename = '',
+      })
+      mock_cache._entries[cache_key] = {
+        lines = { 'cached' },
+        tokens_predicted = 5,
+      }
+
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Latency should be 0 for cached result
+      assert.is_not_nil(mock_ui._shown.info)
+      assert.are.equal(0, mock_ui._shown.info.latency_ms)
+    end)
+  end)
+
+  describe('get_state', function()
+    it('should return completion state for debugging', function()
+      local state = completion.get_state()
+
+      assert.is_table(state)
+      assert.is_boolean(state.pending)
+      assert.is_boolean(state.enabled)
+    end)
+
+    it('should track pending state during request', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Should be pending after request starts
+      local state = completion.get_state()
+      assert.is_true(state.pending)
+    end)
+
+    it('should clear pending state after response', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      -- Simulate successful response
+      local on_success = mock_http._last_request.on_success
+      on_success({ content = 'code', tokens_predicted = 10 })
+
+      -- Should no longer be pending
+      local state = completion.get_state()
+      assert.is_false(state.pending)
+    end)
+
+    it('should track last latency', function()
+      completion.trigger()
+
+      local timer = created_timers[#created_timers]
+      timer._callback()
+
+      local on_success = mock_http._last_request.on_success
+      on_success({ content = 'code', tokens_predicted = 10 })
+
+      local state = completion.get_state()
+      -- Latency should be set (may be 0 in tests due to timing)
+      assert.is_not_nil(state.last_latency_ms)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/config_spec.lua b/sweep.nvim/tests/sweep/config_spec.lua
new file mode 100644
index 0000000..ed21ccf
--- /dev/null
+++ b/sweep.nvim/tests/sweep/config_spec.lua
@@ -0,0 +1,82 @@
+-- Tests for sweep.config module
+
+describe('sweep.config', function()
+  local config
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.config'] = nil
+    config = require('sweep.config')
+  end)
+
+  describe('defaults', function()
+    it('should have default endpoint', function()
+      assert.is_not_nil(config.defaults.server.endpoint)
+      assert.is_true(config.defaults.server.endpoint:match('^http') ~= nil)
+    end)
+
+    it('should have default keymaps', function()
+      assert.is_not_nil(config.defaults.keymaps.trigger)
+      assert.is_not_nil(config.defaults.keymaps.accept_full)
+      assert.is_not_nil(config.defaults.keymaps.accept_line)
+      assert.is_not_nil(config.defaults.keymaps.accept_word)
+      assert.is_not_nil(config.defaults.keymaps.dismiss)
+    end)
+
+    it('should have context settings', function()
+      assert.is_true(config.defaults.context.prefix_lines > 0)
+      assert.is_true(config.defaults.context.suffix_lines > 0)
+      assert.is_true(config.defaults.context.max_ring_chunks > 0)
+    end)
+
+    it('should enable LSP and treesitter by default', function()
+      assert.is_true(config.defaults.context.use_lsp)
+      assert.is_true(config.defaults.context.use_treesitter)
+    end)
+
+    it('should enable prompt caching by default', function()
+      assert.is_true(config.defaults.server.cache_prompt)
+    end)
+  end)
+
+  describe('setup', function()
+    it('should use defaults when no options provided', function()
+      config.setup()
+      assert.are.same(config.defaults.keymaps, config.options.keymaps)
+    end)
+
+    it('should merge user options with defaults', function()
+      config.setup({
+        debounce_ms = 200,
+        keymaps = {
+          trigger = '',
+        },
+      })
+
+      assert.are.equal(200, config.options.debounce_ms)
+      assert.are.equal('', config.options.keymaps.trigger)
+      -- Other keymaps should remain default
+      assert.are.equal(config.defaults.keymaps.accept_full, config.options.keymaps.accept_full)
+    end)
+
+    it('should deep merge nested options', function()
+      config.setup({
+        server = {
+          timeout = 10000,
+        },
+      })
+
+      assert.are.equal(10000, config.options.server.timeout)
+      -- Other server options should remain default
+      assert.are.equal(config.defaults.server.n_predict, config.options.server.n_predict)
+    end)
+  end)
+
+  describe('get', function()
+    it('should return current options', function()
+      config.setup({ debounce_ms = 300 })
+      local opts = config.get()
+      assert.are.equal(300, opts.debounce_ms)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/context_spec.lua b/sweep.nvim/tests/sweep/context_spec.lua
new file mode 100644
index 0000000..ef1a458
--- /dev/null
+++ b/sweep.nvim/tests/sweep/context_spec.lua
@@ -0,0 +1,776 @@
+-- Tests for sweep.context module (LSP and treesitter context extraction)
+
+describe('sweep.context', function()
+  local context
+
+  -- Mock data for tests
+  local mock_buffer_lines = {}
+  local mock_buffer_name = 'test.lua'
+  local mock_filetype = 'lua'
+  local mock_lsp_clients = {}
+  local mock_treesitter_available = true
+  local mock_treesitter_parser = nil
+  local mock_node_at_cursor = nil
+  local mock_lsp_responses = {}
+
+  -- Save original vim functions
+  local original_nvim_buf_get_lines
+  local original_nvim_buf_get_name
+  local original_nvim_get_option_value
+  local original_lsp_get_clients
+  local original_lsp_buf_request
+  local original_treesitter_get_parser
+  local original_treesitter_get_node
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.context'] = nil
+    package.loaded['sweep.config'] = nil
+
+    -- Setup config with defaults
+    local config = require('sweep.config')
+    config.setup()
+
+    -- Save originals
+    original_nvim_buf_get_lines = vim.api.nvim_buf_get_lines
+    original_nvim_buf_get_name = vim.api.nvim_buf_get_name
+    original_nvim_get_option_value = vim.api.nvim_get_option_value
+    original_lsp_get_clients = vim.lsp.get_clients
+    original_lsp_buf_request = vim.lsp.buf_request
+    original_treesitter_get_parser = vim.treesitter.get_parser
+    original_treesitter_get_node = vim.treesitter.get_node
+
+    -- Mock vim.api.nvim_buf_get_lines
+    vim.api.nvim_buf_get_lines = function(bufnr, start_row, end_row, strict_indexing)
+      local result = {}
+      local actual_end = end_row == -1 and #mock_buffer_lines or end_row
+      for i = start_row + 1, math.min(actual_end, #mock_buffer_lines) do
+        if mock_buffer_lines[i] then
+          table.insert(result, mock_buffer_lines[i])
+        end
+      end
+      return result
+    end
+
+    -- Mock vim.api.nvim_buf_get_name
+    vim.api.nvim_buf_get_name = function(bufnr)
+      return mock_buffer_name
+    end
+
+    -- Mock vim.api.nvim_get_option_value
+    vim.api.nvim_get_option_value = function(name, opts)
+      if name == 'filetype' then
+        return mock_filetype
+      end
+      return ''
+    end
+
+    -- Mock vim.lsp.get_clients
+    vim.lsp.get_clients = function(opts)
+      return mock_lsp_clients
+    end
+
+    -- Mock vim.lsp.buf_request
+    vim.lsp.buf_request = function(bufnr, method, params, callback)
+      local response = mock_lsp_responses[method]
+      if response then
+        -- Simulate async callback
+        vim.schedule(function()
+          callback(nil, response, { client_id = 1 })
+        end)
+        return true, 1
+      end
+      return false, nil
+    end
+
+    -- Mock vim.treesitter.get_parser
+    vim.treesitter.get_parser = function(bufnr, lang)
+      if not mock_treesitter_available then
+        error('No parser available')
+      end
+      return mock_treesitter_parser
+    end
+
+    -- Mock vim.treesitter.get_node
+    vim.treesitter.get_node = function(opts)
+      return mock_node_at_cursor
+    end
+
+    -- Reset mock data
+    mock_buffer_lines = {}
+    mock_buffer_name = 'test.lua'
+    mock_filetype = 'lua'
+    mock_lsp_clients = {}
+    mock_treesitter_available = true
+    mock_treesitter_parser = nil
+    mock_node_at_cursor = nil
+    mock_lsp_responses = {}
+
+    context = require('sweep.context')
+  end)
+
+  after_each(function()
+    -- Restore originals
+    vim.api.nvim_buf_get_lines = original_nvim_buf_get_lines
+    vim.api.nvim_buf_get_name = original_nvim_buf_get_name
+    vim.api.nvim_get_option_value = original_nvim_get_option_value
+    vim.lsp.get_clients = original_lsp_get_clients
+    vim.lsp.buf_request = original_lsp_buf_request
+    vim.treesitter.get_parser = original_treesitter_get_parser
+    vim.treesitter.get_node = original_treesitter_get_node
+  end)
+
+  describe('get_scope', function()
+    -- Helper to create mock treesitter nodes
+    local function create_mock_node(node_type, name, start_row, end_row, parent)
+      local node = {
+        type = function() return node_type end,
+        start = function() return start_row, 0, 0 end,
+        end_ = function() return end_row, 0, 0 end,
+        parent = function() return parent end,
+        -- For named child to get function name
+        field = function(self, field_name)
+          if field_name == 'name' then
+            return { {
+              type = function() return 'identifier' end,
+              -- Mock getting text via treesitter query
+              _name = name,
+            } }
+          end
+          return {}
+        end,
+      }
+      return node
+    end
+
+    it('should return nil when treesitter is not available', function()
+      mock_treesitter_available = false
+
+      local scope = context.get_scope(0, 5)
+      assert.is_nil(scope)
+    end)
+
+    it('should return nil when no node at cursor', function()
+      mock_treesitter_available = true
+      mock_node_at_cursor = nil
+
+      local scope = context.get_scope(0, 5)
+      assert.is_nil(scope)
+    end)
+
+    it('should find enclosing function in Lua', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'local M = {}',
+        '',
+        'function M.hello(name)',
+        '  local greeting = "Hello"',
+        '  return greeting .. name',
+        'end',
+        '',
+        'return M',
+      }
+
+      -- Create a node structure: identifier -> local_declaration -> function_declaration
+      local func_node = create_mock_node('function_declaration', 'M.hello', 2, 5, nil)
+      local inner_node = create_mock_node('identifier', nil, 3, 3, func_node)
+      mock_node_at_cursor = inner_node
+
+      local scope = context.get_scope(0, 3)
+
+      assert.is_not_nil(scope)
+      assert.are.equal('function', scope.type)
+      assert.are.equal(2, scope.range.start_row)
+      assert.are.equal(5, scope.range.end_row)
+    end)
+
+    it('should find enclosing method in Lua', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'local MyClass = {}',
+        '',
+        'function MyClass:init()',
+        '  self.value = 0',
+        'end',
+      }
+
+      local method_node = create_mock_node('function_declaration', 'MyClass:init', 2, 4, nil)
+      local inner_node = create_mock_node('assignment_statement', nil, 3, 3, method_node)
+      mock_node_at_cursor = inner_node
+
+      local scope = context.get_scope(0, 3)
+
+      assert.is_not_nil(scope)
+      assert.are.equal('function', scope.type)
+    end)
+
+    it('should return scope content', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'function test()',
+        '  local x = 1',
+        '  return x',
+        'end',
+      }
+
+      local func_node = create_mock_node('function_declaration', 'test', 0, 3, nil)
+      local inner_node = create_mock_node('identifier', nil, 1, 1, func_node)
+      mock_node_at_cursor = inner_node
+
+      local scope = context.get_scope(0, 1)
+
+      assert.is_not_nil(scope)
+      assert.is_not_nil(scope.content)
+      assert.is_true(scope.content:find('function test') ~= nil)
+      assert.is_true(scope.content:find('return x') ~= nil)
+    end)
+
+    it('should limit scope content to max lines', function()
+      mock_filetype = 'lua'
+      -- Create a very long function
+      mock_buffer_lines = { 'function long()' }
+      for i = 1, 100 do
+        table.insert(mock_buffer_lines, '  line ' .. i)
+      end
+      table.insert(mock_buffer_lines, 'end')
+
+      local func_node = create_mock_node('function_declaration', 'long', 0, #mock_buffer_lines - 1, nil)
+      local inner_node = create_mock_node('identifier', nil, 50, 50, func_node)
+      mock_node_at_cursor = inner_node
+
+      local scope = context.get_scope(0, 50, { max_lines = 50 })
+
+      assert.is_not_nil(scope)
+      -- Content should be truncated
+      local line_count = select(2, scope.content:gsub('\n', '\n')) + 1
+      assert.is_true(line_count <= 50)
+    end)
+  end)
+
+  describe('get_imports', function()
+    it('should extract require statements from Lua files', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'local utils = require("utils")',
+        'local http = require("sweep.http")',
+        '',
+        'local M = {}',
+        '',
+        'function M.test()',
+        '  local inner = require("inner")', -- should not be extracted (not at top)
+        'end',
+        '',
+        'return M',
+      }
+
+      local imports = context.get_imports(0)
+
+      assert.is_not_nil(imports)
+      assert.are.equal(2, #imports)
+      assert.is_true(imports[1]:find('require%("utils"%)') ~= nil)
+      assert.is_true(imports[2]:find('require%("sweep.http"%)') ~= nil)
+    end)
+
+    it('should extract import statements from Python files', function()
+      mock_filetype = 'python'
+      mock_buffer_lines = {
+        'import os',
+        'import sys',
+        'from typing import List, Dict',
+        'from dataclasses import dataclass',
+        '',
+        'class MyClass:',
+        '    pass',
+      }
+
+      local imports = context.get_imports(0)
+
+      assert.is_not_nil(imports)
+      assert.is_true(#imports >= 2)
+    end)
+
+    it('should return empty array when no imports', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'local M = {}',
+        '',
+        'function M.test()',
+        '  return 1',
+        'end',
+        '',
+        'return M',
+      }
+
+      local imports = context.get_imports(0)
+
+      assert.is_not_nil(imports)
+      assert.are.equal(0, #imports)
+    end)
+
+    it('should handle empty buffer', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {}
+
+      local imports = context.get_imports(0)
+
+      assert.is_not_nil(imports)
+      assert.are.equal(0, #imports)
+    end)
+
+    it('should limit import extraction to first N lines', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {}
+      -- Create many require statements
+      for i = 1, 100 do
+        table.insert(mock_buffer_lines, string.format('local mod%d = require("mod%d")', i, i))
+      end
+
+      local imports = context.get_imports(0, { max_lines = 30 })
+
+      -- Should only scan first 30 lines
+      assert.is_true(#imports <= 30)
+    end)
+  end)
+
+  describe('get_definitions', function()
+    it('should call callback with empty array when no LSP clients', function()
+      mock_lsp_clients = {}
+      local callback_called = false
+      local received_definitions = nil
+
+      context.get_definitions({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+      }, function(definitions)
+        callback_called = true
+        received_definitions = definitions
+      end)
+
+      -- Since no LSP, callback should be called synchronously with empty array
+      assert.is_true(callback_called)
+      assert.is_not_nil(received_definitions)
+      assert.are.equal(0, #received_definitions)
+    end)
+
+    it('should request definitions from LSP', function()
+      mock_lsp_clients = {
+        { id = 1, name = 'lua_ls' },
+      }
+      mock_lsp_responses['textDocument/definition'] = {
+        {
+          uri = 'file:///path/to/file.lua',
+          range = {
+            start = { line = 10, character = 0 },
+            ['end'] = { line = 20, character = 0 },
+          },
+        },
+      }
+
+      local callback_called = false
+      context.get_definitions({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+      }, function(definitions)
+        callback_called = true
+      end)
+
+      -- Wait for async callback
+      vim.wait(100, function() return callback_called end)
+      assert.is_true(callback_called)
+    end)
+
+    it('should handle LSP errors gracefully', function()
+      mock_lsp_clients = {
+        { id = 1, name = 'lua_ls' },
+      }
+
+      -- Override buf_request to simulate error
+      vim.lsp.buf_request = function(bufnr, method, params, callback)
+        vim.schedule(function()
+          callback('LSP Error', nil, { client_id = 1 })
+        end)
+        return true, 1
+      end
+
+      local callback_called = false
+      local received_definitions = nil
+
+      context.get_definitions({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+      }, function(definitions)
+        callback_called = true
+        received_definitions = definitions
+      end)
+
+      vim.wait(100, function() return callback_called end)
+      assert.is_true(callback_called)
+      assert.is_not_nil(received_definitions)
+      -- Should return empty on error, not crash
+      assert.are.equal(0, #received_definitions)
+    end)
+  end)
+
+  describe('get', function()
+    it('should return context object with all fields', function()
+      mock_buffer_lines = {
+        'local utils = require("utils")',
+        '',
+        'local function test()',
+        '  local x = 1',
+        'end',
+      }
+      mock_lsp_clients = {}
+      mock_treesitter_available = false
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 3,
+        col = 5,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx)
+      assert.is_not_nil(ctx.definitions)
+      assert.is_not_nil(ctx.imports)
+      assert.is_not_nil(ctx.formatted)
+    end)
+
+    it('should include imports in context', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'local utils = require("utils")',
+        'local http = require("http")',
+        '',
+        'local M = {}',
+        'return M',
+      }
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 3,
+        col = 0,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx.imports)
+      assert.are.equal(2, #ctx.imports)
+    end)
+
+    it('should handle missing LSP gracefully', function()
+      mock_lsp_clients = {}
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+        use_lsp = true,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx)
+      assert.is_not_nil(ctx.definitions)
+      assert.are.equal(0, #ctx.definitions)
+    end)
+
+    it('should handle missing treesitter gracefully', function()
+      mock_treesitter_available = false
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+        use_lsp = false,
+        use_treesitter = true,
+      })
+
+      assert.is_not_nil(ctx)
+      assert.is_nil(ctx.scope)
+    end)
+
+    it('should skip LSP when use_lsp is false', function()
+      mock_lsp_clients = {
+        { id = 1, name = 'lua_ls' },
+      }
+      local lsp_called = false
+      vim.lsp.buf_request = function(...)
+        lsp_called = true
+        return false, nil
+      end
+
+      context.get({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_false(lsp_called)
+    end)
+
+    it('should skip treesitter when use_treesitter is false', function()
+      local ts_called = false
+      vim.treesitter.get_node = function(...)
+        ts_called = true
+        return nil
+      end
+
+      context.get({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_false(ts_called)
+    end)
+  end)
+
+  describe('formatted output', function()
+    it('should have section markers', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'local utils = require("utils")',
+        '',
+        'local M = {}',
+        'return M',
+      }
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 2,
+        col = 0,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx.formatted)
+      -- Should have import section when imports exist
+      if #ctx.imports > 0 then
+        assert.is_true(ctx.formatted:find('Imports') ~= nil or ctx.formatted:find('imports') ~= nil)
+      end
+    end)
+
+    it('should include scope section when scope is found', function()
+      mock_filetype = 'lua'
+      mock_buffer_lines = {
+        'function test()',
+        '  local x = 1',
+        '  return x',
+        'end',
+      }
+
+      -- Create mock scope result
+      local func_node = {
+        type = function() return 'function_declaration' end,
+        start = function() return 0, 0, 0 end,
+        end_ = function() return 3, 0, 0 end,
+        parent = function() return nil end,
+        field = function() return {} end,
+      }
+      local inner_node = {
+        type = function() return 'identifier' end,
+        start = function() return 1, 0, 0 end,
+        end_ = function() return 1, 0, 0 end,
+        parent = function() return func_node end,
+        field = function() return {} end,
+      }
+      mock_node_at_cursor = inner_node
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 1,
+        col = 5,
+        use_lsp = false,
+        use_treesitter = true,
+      })
+
+      if ctx.scope then
+        assert.is_true(ctx.formatted:find('scope') ~= nil or ctx.formatted:find('Scope') ~= nil or ctx.formatted:find('function') ~= nil)
+      end
+    end)
+
+    it('should keep total formatted output under limit', function()
+      mock_filetype = 'lua'
+      -- Create lots of imports
+      mock_buffer_lines = {}
+      for i = 1, 50 do
+        table.insert(mock_buffer_lines, string.format('local mod%d = require("module%d")', i, i))
+      end
+      table.insert(mock_buffer_lines, '')
+      table.insert(mock_buffer_lines, 'local M = {}')
+      table.insert(mock_buffer_lines, 'return M')
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 51,
+        col = 0,
+        use_lsp = false,
+        use_treesitter = false,
+        max_formatted_lines = 100,
+      })
+
+      assert.is_not_nil(ctx.formatted)
+      local line_count = select(2, ctx.formatted:gsub('\n', '\n')) + 1
+      -- Should respect the limit (allowing some margin for headers)
+      assert.is_true(line_count <= 150)
+    end)
+  end)
+
+  describe('content truncation', function()
+    it('should truncate long definitions', function()
+      -- This tests that definition content is limited
+      mock_lsp_clients = {
+        { id = 1, name = 'lua_ls' },
+      }
+
+      -- Mock a very long definition response
+      mock_lsp_responses['textDocument/definition'] = {
+        {
+          uri = 'file:///path/to/file.lua',
+          range = {
+            start = { line = 0, character = 0 },
+            ['end'] = { line = 100, character = 0 },
+          },
+        },
+      }
+
+      -- The implementation should truncate to max_definition_lines
+      -- This is tested indirectly through the formatted output
+      local callback_called = false
+      context.get_definitions({
+        bufnr = 0,
+        row = 5,
+        col = 10,
+        max_lines = 50,
+      }, function(definitions)
+        callback_called = true
+        -- Definitions should be returned (possibly truncated)
+      end)
+
+      vim.wait(100, function() return callback_called end)
+      assert.is_true(callback_called)
+    end)
+  end)
+
+  describe('language support', function()
+    describe('Lua files', function()
+      it('should recognize Lua require patterns', function()
+        mock_filetype = 'lua'
+        mock_buffer_lines = {
+          'local a = require("module_a")',
+          "local b = require('module_b')",
+          'local c = require "module_c"',
+          '',
+          'return {}',
+        }
+
+        local imports = context.get_imports(0)
+
+        assert.are.equal(3, #imports)
+      end)
+    end)
+
+    describe('Python files', function()
+      it('should recognize Python import patterns', function()
+        mock_filetype = 'python'
+        mock_buffer_lines = {
+          'import os',
+          'import sys',
+          'from pathlib import Path',
+          'from typing import List, Optional',
+          '',
+          'def main():',
+          '    pass',
+        }
+
+        local imports = context.get_imports(0)
+
+        assert.is_true(#imports >= 4)
+      end)
+    end)
+
+    describe('JavaScript/TypeScript files', function()
+      it('should recognize JS import patterns', function()
+        mock_filetype = 'javascript'
+        mock_buffer_lines = {
+          "import React from 'react';",
+          "import { useState } from 'react';",
+          "const fs = require('fs');",
+          '',
+          'function App() {}',
+        }
+
+        local imports = context.get_imports(0)
+
+        assert.is_true(#imports >= 2)
+      end)
+    end)
+  end)
+
+  describe('edge cases', function()
+    it('should handle buffer 0 (current buffer)', function()
+      mock_buffer_lines = { 'local M = {}', 'return M' }
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 0,
+        col = 0,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx)
+    end)
+
+    it('should handle cursor at file start', function()
+      mock_buffer_lines = { 'first line', 'second line' }
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 0,
+        col = 0,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx)
+    end)
+
+    it('should handle cursor at file end', function()
+      mock_buffer_lines = { 'first line', 'last line' }
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 1,
+        col = 9,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx)
+    end)
+
+    it('should handle empty buffer', function()
+      mock_buffer_lines = {}
+
+      local ctx = context.get({
+        bufnr = 0,
+        row = 0,
+        col = 0,
+        use_lsp = false,
+        use_treesitter = false,
+      })
+
+      assert.is_not_nil(ctx)
+      assert.are.equal(0, #ctx.imports)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/debug_spec.lua b/sweep.nvim/tests/sweep/debug_spec.lua
new file mode 100644
index 0000000..b7a7850
--- /dev/null
+++ b/sweep.nvim/tests/sweep/debug_spec.lua
@@ -0,0 +1,232 @@
+-- Tests for sweep.debug module
+
+describe('sweep.debug', function()
+  local debug_module
+
+  before_each(function()
+    -- Reset module state
+    package.loaded['sweep.debug'] = nil
+    package.loaded['sweep.config'] = nil
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.cache'] = nil
+    package.loaded['sweep.ring'] = nil
+    package.loaded['sweep'] = nil
+
+    -- Setup config first
+    local config = require('sweep.config')
+    config.setup({
+      debounce_ms = 100,
+      server = {
+        endpoint = 'http://localhost:8080',
+        timeout = 5000,
+        n_predict = 128,
+      },
+      context = {
+        prefix_lines = 100,
+        suffix_lines = 50,
+        use_lsp = true,
+        use_treesitter = true,
+      },
+    })
+
+    debug_module = require('sweep.debug')
+  end)
+
+  after_each(function()
+    -- Clean up any open panes
+    if debug_module and debug_module.close_pane then
+      pcall(debug_module.close_pane)
+    end
+
+    package.loaded['sweep.debug'] = nil
+    package.loaded['sweep.config'] = nil
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.cache'] = nil
+    package.loaded['sweep.ring'] = nil
+    package.loaded['sweep'] = nil
+  end)
+
+  describe('get_info', function()
+    it('should return a table with required keys', function()
+      local info = debug_module.get_info()
+
+      assert.is_table(info)
+      assert.is_not_nil(info.enabled)
+      assert.is_not_nil(info.completion)
+      assert.is_not_nil(info.cache)
+      assert.is_not_nil(info.ring)
+      assert.is_not_nil(info.config)
+    end)
+
+    it('should include completion state', function()
+      local info = debug_module.get_info()
+
+      assert.is_table(info.completion)
+      assert.is_boolean(info.completion.pending)
+    end)
+
+    it('should include cache stats', function()
+      local info = debug_module.get_info()
+
+      assert.is_table(info.cache)
+      assert.is_number(info.cache.entries)
+      assert.is_number(info.cache.hits)
+      assert.is_number(info.cache.misses)
+    end)
+
+    it('should include ring buffer stats', function()
+      local info = debug_module.get_info()
+
+      assert.is_table(info.ring)
+      assert.is_number(info.ring.chunks)
+      assert.is_table(info.ring.filetypes)
+    end)
+
+    it('should include config', function()
+      local info = debug_module.get_info()
+
+      assert.is_table(info.config)
+      assert.are.equal(100, info.config.debounce_ms)
+    end)
+
+    it('should reflect ring buffer changes', function()
+      local ring = require('sweep.ring')
+      ring.setup({ max_chunks = 16, chunk_size = 64 })
+
+      -- Add some chunks
+      ring.add({
+        content = 'test content 1',
+        filename = 'test1.lua',
+        filetype = 'lua',
+        source = 'test',
+      })
+      ring.add({
+        content = 'test content 2',
+        filename = 'test2.py',
+        filetype = 'python',
+        source = 'test',
+      })
+
+      local info = debug_module.get_info()
+
+      assert.are.equal(2, info.ring.chunks)
+      assert.are.equal(1, info.ring.filetypes.lua)
+      assert.are.equal(1, info.ring.filetypes.python)
+    end)
+
+    it('should reflect cache changes', function()
+      local cache = require('sweep.cache')
+      cache.setup({ max_entries = 100, ttl_ms = 60000 })
+
+      -- Add some cache entries
+      cache.set('key1', { lines = { 'test1' } })
+      cache.set('key2', { lines = { 'test2' } })
+
+      -- Access one to get a hit
+      cache.get('key1')
+      -- Try to get a non-existent key for a miss
+      cache.get('nonexistent')
+
+      local info = debug_module.get_info()
+
+      assert.are.equal(2, info.cache.entries)
+      assert.are.equal(1, info.cache.hits)
+      assert.are.equal(1, info.cache.misses)
+    end)
+  end)
+
+  describe('show_pane', function()
+    it('should create a floating window', function()
+      debug_module.show_pane()
+
+      assert.is_true(debug_module.is_pane_open())
+    end)
+
+    it('should be closeable', function()
+      debug_module.show_pane()
+      assert.is_true(debug_module.is_pane_open())
+
+      debug_module.close_pane()
+      assert.is_false(debug_module.is_pane_open())
+    end)
+
+    it('should replace existing pane when called multiple times', function()
+      debug_module.show_pane()
+      local first_open = debug_module.is_pane_open()
+
+      debug_module.show_pane()
+      local second_open = debug_module.is_pane_open()
+
+      assert.is_true(first_open)
+      assert.is_true(second_open)
+
+      debug_module.close_pane()
+    end)
+  end)
+
+  describe('close_pane', function()
+    it('should be safe to call when no pane is open', function()
+      assert.has_no.errors(function()
+        debug_module.close_pane()
+      end)
+    end)
+
+    it('should close the pane buffer and window', function()
+      debug_module.show_pane()
+      debug_module.close_pane()
+
+      assert.is_false(debug_module.is_pane_open())
+    end)
+  end)
+
+  describe('is_pane_open', function()
+    it('should return false when no pane is open', function()
+      assert.is_false(debug_module.is_pane_open())
+    end)
+
+    it('should return true when pane is open', function()
+      debug_module.show_pane()
+      assert.is_true(debug_module.is_pane_open())
+      debug_module.close_pane()
+    end)
+  end)
+
+  describe('refresh_pane', function()
+    it('should do nothing if pane is not open', function()
+      assert.has_no.errors(function()
+        debug_module.refresh_pane()
+      end)
+    end)
+
+    it('should refresh pane content if open', function()
+      debug_module.show_pane()
+
+      -- Add some data to change the state
+      local cache = require('sweep.cache')
+      cache.setup({ max_entries = 100, ttl_ms = 60000 })
+      cache.set('test', { lines = { 'test' } })
+
+      -- Refresh should work without error
+      assert.has_no.errors(function()
+        debug_module.refresh_pane()
+      end)
+
+      assert.is_true(debug_module.is_pane_open())
+      debug_module.close_pane()
+    end)
+  end)
+
+  describe('log', function()
+    it('should not crash when called', function()
+      assert.has_no.errors(function()
+        debug_module.log('test message')
+      end)
+    end)
+
+    it('should accept a log level', function()
+      assert.has_no.errors(function()
+        debug_module.log('test message', vim.log.levels.WARN)
+      end)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/edits_spec.lua b/sweep.nvim/tests/sweep/edits_spec.lua
new file mode 100644
index 0000000..71502b0
--- /dev/null
+++ b/sweep.nvim/tests/sweep/edits_spec.lua
@@ -0,0 +1,507 @@
+-- Tests for sweep.edits module (edit tracking for next-edit prediction)
+
+describe('sweep.edits', function()
+  local edits
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.edits'] = nil
+    edits = require('sweep.edits')
+    edits.setup({
+      max_edits = 5,
+      max_lines = 10,
+      context_lines = 3,
+    })
+  end)
+
+  describe('setup', function()
+    it('should initialize with given options', function()
+      edits.setup({
+        max_edits = 20,
+        max_lines = 50,
+        context_lines = 5,
+      })
+      -- Module should be initialized (verify via get_history returning empty array)
+      local history = edits.get_history()
+      assert.are.same({}, history)
+    end)
+
+    it('should use default values when options not provided', function()
+      package.loaded['sweep.edits'] = nil
+      edits = require('sweep.edits')
+      edits.setup({})
+      local history = edits.get_history()
+      assert.are.same({}, history)
+    end)
+  end)
+
+  describe('record', function()
+    it('should store edit with metadata', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 10,
+        end_line = 12,
+        old_lines = { 'original line' },
+        new_lines = { 'updated line' },
+        filename = '/path/to/file.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(1, #history)
+      assert.are.same({ 'original line' }, history[1].old_lines)
+      assert.are.same({ 'updated line' }, history[1].new_lines)
+      assert.are.equal('/path/to/file.lua', history[1].filename)
+      assert.is_not_nil(history[1].timestamp)
+    end)
+
+    it('should include filename in each edit', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old' },
+        new_lines = { 'new' },
+        filename = '/test/file.py',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal('/test/file.py', history[1].filename)
+    end)
+
+    it('should record edits with bufnr for buffer tracking', function()
+      edits.record({
+        bufnr = 42,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old' },
+        new_lines = { 'new' },
+        filename = '/file.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(42, history[1].bufnr)
+    end)
+  end)
+
+  describe('edit ordering', function()
+    it('should order edits by recency (newest first)', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'first old' },
+        new_lines = { 'first new' },
+        filename = '/first.lua',
+      })
+
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'second old' },
+        new_lines = { 'second new' },
+        filename = '/second.lua',
+      })
+
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'third old' },
+        new_lines = { 'third new' },
+        filename = '/third.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(3, #history)
+      -- Newest first
+      assert.are.same({ 'third new' }, history[1].new_lines)
+      assert.are.same({ 'second new' }, history[2].new_lines)
+      assert.are.same({ 'first new' }, history[3].new_lines)
+    end)
+  end)
+
+  describe('max_edits limit', function()
+    it('should respect max_edits limit and evict oldest', function()
+      -- max_edits is 5
+      for i = 1, 7 do
+        edits.record({
+          bufnr = 1,
+          start_line = 1,
+          end_line = 1,
+          old_lines = { 'old' .. i },
+          new_lines = { 'new' .. i },
+          filename = '/file' .. i .. '.lua',
+        })
+      end
+
+      local history = edits.get_history()
+      assert.are.equal(5, #history)
+
+      -- Check that oldest edits (1 and 2) were evicted
+      local filenames = {}
+      for _, edit in ipairs(history) do
+        table.insert(filenames, edit.filename)
+      end
+      assert.is_false(vim.tbl_contains(filenames, '/file1.lua'))
+      assert.is_false(vim.tbl_contains(filenames, '/file2.lua'))
+      assert.is_true(vim.tbl_contains(filenames, '/file7.lua'))
+    end)
+  end)
+
+  describe('get_context', function()
+    it('should return properly formatted string with tags', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'function old() {', '  return 1', '}' },
+        new_lines = { 'function new() {', '  return 2', '}' },
+        filename = '/test.lua',
+      })
+
+      local context = edits.get_context()
+
+      -- Check for proper XML-style tags
+      assert.is_true(context:find('', 1, true) ~= nil)
+      assert.is_true(context:find('', 1, true) ~= nil)
+      assert.is_true(context:find('', 1, true) ~= nil)
+      assert.is_true(context:find('', 1, true) ~= nil)
+      assert.is_true(context:find('', 1, true) ~= nil)
+      assert.is_true(context:find('', 1, true) ~= nil)
+    end)
+
+    it('should include old and new code content', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'original code here' },
+        new_lines = { 'updated code here' },
+        filename = '/test.lua',
+      })
+
+      local context = edits.get_context()
+      assert.is_true(context:find('original code here', 1, true) ~= nil)
+      assert.is_true(context:find('updated code here', 1, true) ~= nil)
+    end)
+
+    it('should return empty string when no edits recorded', function()
+      local context = edits.get_context()
+      assert.are.equal('', context)
+    end)
+
+    it('should format multiple edits with separate tags', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'first old' },
+        new_lines = { 'first new' },
+        filename = '/first.lua',
+      })
+
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'second old' },
+        new_lines = { 'second new' },
+        filename = '/second.lua',
+      })
+
+      local context = edits.get_context()
+
+      -- Should have two  blocks
+      local edit_count = 0
+      for _ in context:gmatch('') do
+        edit_count = edit_count + 1
+      end
+      assert.are.equal(2, edit_count)
+    end)
+
+    it('should show most recent edits first in context', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'first old' },
+        new_lines = { 'first new' },
+        filename = '/first.lua',
+      })
+
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'second old' },
+        new_lines = { 'second new' },
+        filename = '/second.lua',
+      })
+
+      local context = edits.get_context()
+      local second_pos = context:find('second new', 1, true)
+      local first_pos = context:find('first new', 1, true)
+
+      -- Second (more recent) should appear before first
+      assert.is_true(second_pos < first_pos)
+    end)
+  end)
+
+  describe('clear', function()
+    it('should remove all edits', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old1' },
+        new_lines = { 'new1' },
+        filename = '/file1.lua',
+      })
+
+      edits.record({
+        bufnr = 2,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old2' },
+        new_lines = { 'new2' },
+        filename = '/file2.lua',
+      })
+
+      edits.clear()
+
+      local history = edits.get_history()
+      assert.are.same({}, history)
+      assert.are.equal('', edits.get_context())
+    end)
+  end)
+
+  describe('clear_buffer', function()
+    it('should only remove edits for the specified buffer', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'buf1 old' },
+        new_lines = { 'buf1 new' },
+        filename = '/buf1.lua',
+      })
+
+      edits.record({
+        bufnr = 2,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'buf2 old' },
+        new_lines = { 'buf2 new' },
+        filename = '/buf2.lua',
+      })
+
+      edits.record({
+        bufnr = 1,
+        start_line = 5,
+        end_line = 6,
+        old_lines = { 'buf1 old2' },
+        new_lines = { 'buf1 new2' },
+        filename = '/buf1.lua',
+      })
+
+      edits.clear_buffer(1)
+
+      local history = edits.get_history()
+      assert.are.equal(1, #history)
+      assert.are.equal(2, history[1].bufnr)
+      assert.are.equal('/buf2.lua', history[1].filename)
+    end)
+
+    it('should handle clearing non-existent buffer gracefully', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old' },
+        new_lines = { 'new' },
+        filename = '/file.lua',
+      })
+
+      -- Should not error
+      edits.clear_buffer(999)
+
+      local history = edits.get_history()
+      assert.are.equal(1, #history)
+    end)
+  end)
+
+  describe('get_history', function()
+    it('should return raw edit data array', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 10,
+        end_line = 12,
+        old_lines = { 'line1', 'line2' },
+        new_lines = { 'newline1', 'newline2', 'newline3' },
+        filename = '/test.lua',
+      })
+
+      local history = edits.get_history()
+      assert.is_true(type(history) == 'table')
+      assert.are.equal(1, #history)
+
+      local edit = history[1]
+      assert.is_not_nil(edit.timestamp)
+      assert.are.equal('/test.lua', edit.filename)
+      assert.are.same({ 'line1', 'line2' }, edit.old_lines)
+      assert.are.same({ 'newline1', 'newline2', 'newline3' }, edit.new_lines)
+    end)
+
+    it('should return empty table when no edits', function()
+      local history = edits.get_history()
+      assert.are.same({}, history)
+    end)
+  end)
+
+  describe('max_lines truncation', function()
+    it('should truncate long edits to max_lines', function()
+      -- max_lines is 10
+      local old_lines = {}
+      local new_lines = {}
+      for i = 1, 25 do
+        table.insert(old_lines, 'old line ' .. i)
+        table.insert(new_lines, 'new line ' .. i)
+      end
+
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 25,
+        old_lines = old_lines,
+        new_lines = new_lines,
+        filename = '/long.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(1, #history)
+      -- Both old and new lines should be truncated to max_lines
+      assert.are.equal(10, #history[1].old_lines)
+      assert.are.equal(10, #history[1].new_lines)
+      assert.are.equal('old line 1', history[1].old_lines[1])
+      assert.are.equal('old line 10', history[1].old_lines[10])
+    end)
+
+    it('should not truncate edits shorter than max_lines', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 3,
+        old_lines = { 'line1', 'line2', 'line3' },
+        new_lines = { 'new1', 'new2' },
+        filename = '/short.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(3, #history[1].old_lines)
+      assert.are.equal(2, #history[1].new_lines)
+    end)
+  end)
+
+  describe('attach and detach', function()
+    it('should return true when attach is called', function()
+      -- attach returns success indicator
+      local result = edits.attach(1)
+      assert.is_true(result == true or result == nil or result)
+    end)
+
+    it('should track attached buffers', function()
+      edits.attach(1)
+      edits.attach(2)
+
+      -- Detach should work without error
+      edits.detach(1)
+      edits.detach(2)
+    end)
+
+    it('should handle detach on non-attached buffer gracefully', function()
+      -- Should not error
+      edits.detach(999)
+    end)
+
+    it('should handle multiple attach calls on same buffer', function()
+      -- Should not error or create duplicates
+      edits.attach(1)
+      edits.attach(1)
+      edits.detach(1)
+    end)
+  end)
+
+  describe('edge cases', function()
+    it('should handle empty old_lines', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = {},
+        new_lines = { 'new line' },
+        filename = '/file.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(1, #history)
+      assert.are.same({}, history[1].old_lines)
+    end)
+
+    it('should handle empty new_lines', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old line' },
+        new_lines = {},
+        filename = '/file.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.equal(1, #history)
+      assert.are.same({}, history[1].new_lines)
+    end)
+
+    it('should handle nil filename by using empty string', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'old' },
+        new_lines = { 'new' },
+      })
+
+      local history = edits.get_history()
+      assert.are.equal('', history[1].filename)
+    end)
+
+    it('should not record edit when both old and new are empty', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = {},
+        new_lines = {},
+        filename = '/file.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.same({}, history)
+    end)
+
+    it('should not record edit when old and new are identical', function()
+      edits.record({
+        bufnr = 1,
+        start_line = 1,
+        end_line = 1,
+        old_lines = { 'same line' },
+        new_lines = { 'same line' },
+        filename = '/file.lua',
+      })
+
+      local history = edits.get_history()
+      assert.are.same({}, history)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/fim_spec.lua b/sweep.nvim/tests/sweep/fim_spec.lua
new file mode 100644
index 0000000..3f3607d
--- /dev/null
+++ b/sweep.nvim/tests/sweep/fim_spec.lua
@@ -0,0 +1,567 @@
+-- Tests for sweep.fim module (FIM request builder)
+
+describe('sweep.fim', function()
+  local fim
+
+  -- Mock buffer data for tests
+  local mock_buffer_lines = {}
+  local mock_buffer_name = 'test.lua'
+  local mock_filetype = 'lua'
+
+  -- Save original vim.api functions
+  local original_nvim_buf_get_lines
+  local original_nvim_buf_get_name
+  local original_nvim_get_option_value
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.fim'] = nil
+
+    -- Save originals
+    original_nvim_buf_get_lines = vim.api.nvim_buf_get_lines
+    original_nvim_buf_get_name = vim.api.nvim_buf_get_name
+    original_nvim_get_option_value = vim.api.nvim_get_option_value
+
+    -- Mock vim.api.nvim_buf_get_lines
+    vim.api.nvim_buf_get_lines = function(bufnr, start_row, end_row, strict_indexing)
+      local result = {}
+      local actual_end = end_row == -1 and #mock_buffer_lines or end_row
+      for i = start_row + 1, actual_end do
+        if mock_buffer_lines[i] then
+          table.insert(result, mock_buffer_lines[i])
+        end
+      end
+      return result
+    end
+
+    -- Mock vim.api.nvim_buf_get_name
+    vim.api.nvim_buf_get_name = function(bufnr)
+      return mock_buffer_name
+    end
+
+    -- Mock vim.api.nvim_get_option_value
+    vim.api.nvim_get_option_value = function(name, opts)
+      if name == 'filetype' then
+        return mock_filetype
+      end
+      return ''
+    end
+
+    -- Reset mock data
+    mock_buffer_lines = {}
+    mock_buffer_name = 'test.lua'
+    mock_filetype = 'lua'
+
+    fim = require('sweep.fim')
+  end)
+
+  after_each(function()
+    -- Restore originals
+    vim.api.nvim_buf_get_lines = original_nvim_buf_get_lines
+    vim.api.nvim_buf_get_name = original_nvim_buf_get_name
+    vim.api.nvim_get_option_value = original_nvim_get_option_value
+  end)
+
+  describe('build_request', function()
+    describe('prefix extraction', function()
+      it('should extract lines before cursor plus partial current line', function()
+        mock_buffer_lines = {
+          'local M = {}',
+          '',
+          'function M.hello()',
+          '  print("world")',
+          'end',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 3,  -- 0-indexed, line with print
+          col = 8,  -- after '  print'
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        -- Prefix should be lines 0-2 plus partial line 3 up to column 8
+        assert.is_not_nil(result.input_prefix)
+        assert.is_true(result.input_prefix:find('local M = {}') ~= nil)
+        assert.is_true(result.input_prefix:find('function M.hello()') ~= nil)
+        assert.is_true(result.input_prefix:find('  print') ~= nil)
+        -- Should NOT include the part after cursor
+        assert.is_nil(result.input_prefix:find('world'))
+      end)
+
+      it('should respect prefix_lines limit', function()
+        mock_buffer_lines = {
+          'line 1',
+          'line 2',
+          'line 3',
+          'line 4',
+          'line 5',
+          'cursor line',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 5,  -- cursor line
+          col = 6,  -- after 'cursor'
+          prefix_lines = 2,  -- only include 2 lines before cursor line
+          suffix_lines = 50,
+        })
+
+        -- Should only have lines 3, 4 and partial 5 in prefix
+        assert.is_nil(result.input_prefix:find('line 1'))
+        assert.is_nil(result.input_prefix:find('line 2'))
+        assert.is_true(result.input_prefix:find('line 4') ~= nil)
+        assert.is_true(result.input_prefix:find('line 5') ~= nil)
+        assert.is_true(result.input_prefix:find('cursor') ~= nil)
+      end)
+    end)
+
+    describe('suffix extraction', function()
+      it('should extract rest of current line plus lines after cursor', function()
+        mock_buffer_lines = {
+          'local M = {}',
+          '',
+          'function M.hello()',
+          '  print("world")',
+          'end',
+          'return M',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 3,  -- line with print
+          col = 8,  -- after '  print'
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        -- Suffix should be rest of line 3 plus lines 4-5
+        assert.is_not_nil(result.input_suffix)
+        assert.is_true(result.input_suffix:find('world') ~= nil)
+        assert.is_true(result.input_suffix:find('end') ~= nil)
+        assert.is_true(result.input_suffix:find('return M') ~= nil)
+      end)
+
+      it('should respect suffix_lines limit', function()
+        mock_buffer_lines = {
+          'cursor line',
+          'line 1',
+          'line 2',
+          'line 3',
+          'line 4',
+          'line 5',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,  -- cursor line
+          col = 6,  -- after 'cursor'
+          prefix_lines = 100,
+          suffix_lines = 2,  -- only include 2 lines after cursor line
+        })
+
+        -- Should only have rest of line 0 plus lines 1-2
+        assert.is_true(result.input_suffix:find('line 1') ~= nil)
+        assert.is_true(result.input_suffix:find('line 2') ~= nil)
+        assert.is_nil(result.input_suffix:find('line 3'))
+        assert.is_nil(result.input_suffix:find('line 4'))
+      end)
+    end)
+
+    describe('cursor at line boundaries', function()
+      it('should handle cursor at line start', function()
+        mock_buffer_lines = {
+          'line before',
+          'current line',
+          'line after',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 0,  -- at start of line
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        -- Prefix should not include any of current line
+        assert.is_true(result.input_prefix:find('line before\n$') ~= nil)
+        -- Suffix should be entire current line plus lines after
+        assert.is_true(result.input_suffix:find('^current line') ~= nil)
+      end)
+
+      it('should handle cursor at line end', function()
+        mock_buffer_lines = {
+          'line before',
+          'current line',
+          'line after',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 12,  -- at end of 'current line'
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        -- Prefix should include full current line
+        assert.is_true(result.input_prefix:find('current line$') ~= nil)
+        -- Suffix should start with newline (rest of current line is empty)
+        assert.is_true(result.input_suffix:find('^[\n]') ~= nil or result.input_suffix:find('^line after') ~= nil)
+      end)
+    end)
+
+    describe('edge cases', function()
+      it('should handle empty buffer', function()
+        mock_buffer_lines = {}
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 0,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.is_not_nil(result)
+        assert.are.equal('', result.input_prefix)
+        assert.are.equal('', result.input_suffix)
+      end)
+
+      it('should handle cursor at first line of file', function()
+        mock_buffer_lines = {
+          'first line',
+          'second line',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 5,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('first', result.input_prefix)
+        assert.is_true(result.input_suffix:find(' line') ~= nil)
+        assert.is_true(result.input_suffix:find('second line') ~= nil)
+      end)
+
+      it('should handle cursor at last line of file', function()
+        mock_buffer_lines = {
+          'first line',
+          'last line',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 4,  -- after 'last'
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.is_true(result.input_prefix:find('first line') ~= nil)
+        assert.is_true(result.input_prefix:find('last') ~= nil)
+        assert.is_true(result.input_suffix:find(' line') ~= nil)
+      end)
+
+      it('should handle single line buffer', function()
+        mock_buffer_lines = {
+          'only line',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 4,  -- after 'only'
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('only', result.input_prefix)
+        assert.are.equal(' line', result.input_suffix)
+      end)
+
+      it('should handle cursor beyond line length', function()
+        mock_buffer_lines = {
+          'short',
+          'next',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 100,  -- way past end of line
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        -- Should clamp to line length
+        assert.are.equal('short', result.input_prefix)
+      end)
+    end)
+
+    describe('indentation detection', function()
+      it('should detect space indentation', function()
+        mock_buffer_lines = {
+          'function test()',
+          '    indented line',
+          'end',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 10,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('    ', result.metadata.indent)
+      end)
+
+      it('should detect tab indentation', function()
+        mock_buffer_lines = {
+          'function test()',
+          '\t\tindented',
+          'end',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 5,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('\t\t', result.metadata.indent)
+      end)
+
+      it('should return empty string for no indentation', function()
+        mock_buffer_lines = {
+          'no indent',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 2,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('', result.metadata.indent)
+      end)
+
+      it('should detect mixed indentation', function()
+        mock_buffer_lines = {
+          'start',
+          '  \tmixed',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 5,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('  \t', result.metadata.indent)
+      end)
+    end)
+
+    describe('metadata', function()
+      it('should include cursor line text', function()
+        mock_buffer_lines = {
+          'function foo()',
+          '  local x = 1',
+          'end',
+        }
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 8,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('  local x = 1', result.metadata.cursor_line)
+      end)
+
+      it('should include filename', function()
+        mock_buffer_name = '/path/to/file.lua'
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 0,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('file.lua', result.metadata.filename)
+      end)
+
+      it('should include filetype', function()
+        mock_filetype = 'python'
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 0,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('python', result.metadata.filetype)
+      end)
+
+      it('should handle empty filename', function()
+        mock_buffer_name = ''
+
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 0,
+          col = 0,
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.are.equal('', result.metadata.filename)
+      end)
+    end)
+
+    describe('format option', function()
+      before_each(function()
+        mock_buffer_lines = {
+          'prefix code',
+          'cursor here',
+          'suffix code',
+        }
+      end)
+
+      it('should default to infill format', function()
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,  -- after 'cursor'
+          prefix_lines = 100,
+          suffix_lines = 50,
+        })
+
+        assert.is_not_nil(result.input_prefix)
+        assert.is_not_nil(result.input_suffix)
+      end)
+
+      it('should support explicit infill format', function()
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,
+          prefix_lines = 100,
+          suffix_lines = 50,
+          format = 'infill',
+        })
+
+        assert.is_not_nil(result.input_prefix)
+        assert.is_not_nil(result.input_suffix)
+        -- infill format should not have prompt field with tokens
+        assert.is_nil(result.prompt)
+      end)
+
+      it('should support fim_tokens format with PRE/SUF/MID tokens', function()
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,
+          prefix_lines = 100,
+          suffix_lines = 50,
+          format = 'fim_tokens',
+        })
+
+        assert.is_not_nil(result.prompt)
+        assert.is_true(result.prompt:find('
') ~= nil)
+        assert.is_true(result.prompt:find('') ~= nil)
+        assert.is_true(result.prompt:find('') ~= nil)
+      end)
+
+      it('should support custom FIM tokens', function()
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,
+          prefix_lines = 100,
+          suffix_lines = 50,
+          format = 'fim_tokens',
+          fim_tokens = {
+            prefix = '<|fim_prefix|>',
+            suffix = '<|fim_suffix|>',
+            middle = '<|fim_middle|>',
+          },
+        })
+
+        assert.is_not_nil(result.prompt)
+        assert.is_true(result.prompt:find('<|fim_prefix|>') ~= nil)
+        assert.is_true(result.prompt:find('<|fim_suffix|>') ~= nil)
+        assert.is_true(result.prompt:find('<|fim_middle|>') ~= nil)
+      end)
+
+      it('should order tokens as PREFIX + prefix_code + SUFFIX + suffix_code + MIDDLE', function()
+        local result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,
+          prefix_lines = 100,
+          suffix_lines = 50,
+          format = 'fim_tokens',
+        })
+
+        -- Check order: 
...prefix......suffix...
+        local pre_pos = result.prompt:find('
')
+        local suf_pos = result.prompt:find('')
+        local mid_pos = result.prompt:find('')
+
+        assert.is_true(pre_pos < suf_pos)
+        assert.is_true(suf_pos < mid_pos)
+      end)
+    end)
+
+    describe('both formats include same content', function()
+      it('should have equivalent prefix/suffix content', function()
+        mock_buffer_lines = {
+          'local x = 1',
+          'local y = 2',
+          'local z = 3',
+        }
+
+        local infill_result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,  -- after 'local '
+          prefix_lines = 100,
+          suffix_lines = 50,
+          format = 'infill',
+        })
+
+        local fim_result = fim.build_request({
+          bufnr = 0,
+          row = 1,
+          col = 6,
+          prefix_lines = 100,
+          suffix_lines = 50,
+          format = 'fim_tokens',
+        })
+
+        -- Both should extract the same prefix and suffix content
+        assert.is_true(fim_result.prompt:find(infill_result.input_prefix, 1, true) ~= nil)
+        assert.is_true(fim_result.prompt:find(infill_result.input_suffix, 1, true) ~= nil)
+      end)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/http_spec.lua b/sweep.nvim/tests/sweep/http_spec.lua
new file mode 100644
index 0000000..d00309d
--- /dev/null
+++ b/sweep.nvim/tests/sweep/http_spec.lua
@@ -0,0 +1,523 @@
+-- Tests for sweep.http module
+
+describe('sweep.http', function()
+  local http
+  local mock_curl
+  local mock_job
+
+  -- Mock job object that simulates plenary.curl behavior
+  local function create_mock_job(opts)
+    opts = opts or {}
+    local job = {
+      _cancelled = false,
+      pid = opts.pid or 12345,
+      shutdown = function(self)
+        self._cancelled = true
+        if opts.on_shutdown then
+          opts.on_shutdown()
+        end
+      end,
+    }
+    return job
+  end
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.http'] = nil
+
+    -- Create mock curl module
+    mock_job = nil
+    mock_curl = {
+      post = function(opts)
+        -- Store the options for inspection
+        mock_curl._last_opts = opts
+        -- Create and return a mock job
+        mock_job = create_mock_job({ pid = 12345 })
+        return mock_job
+      end,
+      _last_opts = nil,
+    }
+
+    -- Inject mock into package.loaded before requiring http
+    package.loaded['plenary.curl'] = mock_curl
+
+    http = require('sweep.http')
+  end)
+
+  after_each(function()
+    -- Clean up
+    package.loaded['plenary.curl'] = nil
+    package.loaded['sweep.http'] = nil
+  end)
+
+  describe('request', function()
+    it('should send POST request to the correct endpoint', function()
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test prompt' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      assert.is_not_nil(mock_curl._last_opts)
+      assert.are.equal('http://localhost:8080/completion', mock_curl._last_opts.url)
+    end)
+
+    it('should send JSON body with correct content type', function()
+      local test_body = { prompt = 'function hello', n_predict = 128 }
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = test_body,
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      assert.is_not_nil(mock_curl._last_opts)
+      assert.is_not_nil(mock_curl._last_opts.body)
+      -- Body should be JSON encoded
+      local decoded = vim.json.decode(mock_curl._last_opts.body)
+      assert.are.equal('function hello', decoded.prompt)
+      assert.are.equal(128, decoded.n_predict)
+      -- Should have JSON content type header
+      assert.is_not_nil(mock_curl._last_opts.headers)
+      assert.are.equal('application/json', mock_curl._last_opts.headers['Content-Type'])
+    end)
+
+    it('should use provided timeout', function()
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        timeout = 10000,
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      assert.are.equal(10000, mock_curl._last_opts.timeout)
+    end)
+
+    it('should use default timeout when not provided', function()
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      assert.are.equal(5000, mock_curl._last_opts.timeout)
+    end)
+
+    it('should return a cancellable handle', function()
+      local handle = http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      assert.is_not_nil(handle)
+      assert.is_not_nil(handle.id)
+    end)
+
+    it('should call on_success with parsed JSON on successful response', function()
+      local success_called = false
+      local received_response = nil
+
+      -- Override mock to trigger callback
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        -- Simulate async callback with successful response
+        vim.schedule(function()
+          if opts.callback then
+            opts.callback({
+              status = 200,
+              body = vim.json.encode({ content = 'completed code', tokens_predicted = 50 }),
+            })
+          end
+        end)
+        return mock_job
+      end
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function(response)
+          success_called = true
+          received_response = response
+        end,
+        on_error = function() end,
+      })
+
+      -- Wait for scheduled callback
+      vim.wait(100, function() return success_called end)
+
+      assert.is_true(success_called)
+      assert.is_not_nil(received_response)
+      assert.are.equal('completed code', received_response.content)
+      assert.are.equal(50, received_response.tokens_predicted)
+    end)
+
+    it('should call on_error on HTTP error status', function()
+      local error_called = false
+      local received_error = nil
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        vim.schedule(function()
+          if opts.callback then
+            opts.callback({
+              status = 500,
+              body = 'Internal Server Error',
+            })
+          end
+        end)
+        return mock_job
+      end
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function(err)
+          error_called = true
+          received_error = err
+        end,
+      })
+
+      vim.wait(100, function() return error_called end)
+
+      assert.is_true(error_called)
+      assert.is_not_nil(received_error)
+      assert.is_true(received_error:match('500') ~= nil)
+    end)
+
+    it('should call on_error on connection failure', function()
+      local error_called = false
+      local received_error = nil
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        vim.schedule(function()
+          if opts.callback then
+            opts.callback({
+              status = 0,
+              body = nil,
+              exit = 7, -- curl connection refused
+            })
+          end
+        end)
+        return mock_job
+      end
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function(err)
+          error_called = true
+          received_error = err
+        end,
+      })
+
+      vim.wait(100, function() return error_called end)
+
+      assert.is_true(error_called)
+      assert.is_not_nil(received_error)
+    end)
+
+    it('should call on_error on JSON parse failure', function()
+      local error_called = false
+      local received_error = nil
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        vim.schedule(function()
+          if opts.callback then
+            opts.callback({
+              status = 200,
+              body = 'not valid json {{{',
+            })
+          end
+        end)
+        return mock_job
+      end
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function(err)
+          error_called = true
+          received_error = err
+        end,
+      })
+
+      vim.wait(100, function() return error_called end)
+
+      assert.is_true(error_called)
+      assert.is_not_nil(received_error)
+      assert.is_true(received_error:match('[Jj][Ss][Oo][Nn]') ~= nil or received_error:match('parse') ~= nil)
+    end)
+  end)
+
+  describe('cancel', function()
+    it('should cancel an in-flight request', function()
+      local handle = http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      http.cancel(handle)
+
+      assert.is_true(mock_job._cancelled)
+    end)
+
+    it('should not call callbacks after cancellation', function()
+      local success_called = false
+      local error_called = false
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        -- Store callback reference
+        local stored_callback = opts.callback
+        mock_job._trigger_callback = function()
+          if stored_callback then
+            stored_callback({
+              status = 200,
+              body = vim.json.encode({ content = 'test' }),
+            })
+          end
+        end
+        return mock_job
+      end
+
+      local handle = http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function()
+          success_called = true
+        end,
+        on_error = function()
+          error_called = true
+        end,
+      })
+
+      -- Cancel the request
+      http.cancel(handle)
+
+      -- Try to trigger the callback after cancellation
+      vim.schedule(function()
+        if mock_job._trigger_callback then
+          mock_job._trigger_callback()
+        end
+      end)
+
+      vim.wait(100, function() return success_called or error_called end, 10)
+
+      assert.is_false(success_called)
+      assert.is_false(error_called)
+    end)
+
+    it('should handle cancelling non-existent handle gracefully', function()
+      -- Should not throw error
+      assert.has_no.errors(function()
+        http.cancel({ id = 'non-existent-id' })
+      end)
+    end)
+
+    it('should handle cancelling nil handle gracefully', function()
+      assert.has_no.errors(function()
+        http.cancel(nil)
+      end)
+    end)
+  end)
+
+  describe('cancel_all', function()
+    it('should cancel all pending requests', function()
+      local jobs = {}
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        local job = create_mock_job({ pid = #jobs + 1 })
+        table.insert(jobs, job)
+        return job
+      end
+
+      -- Make multiple requests
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test1' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test2' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test3' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      assert.are.equal(3, #jobs)
+
+      -- Cancel all
+      http.cancel_all()
+
+      -- All jobs should be cancelled
+      for _, job in ipairs(jobs) do
+        assert.is_true(job._cancelled)
+      end
+    end)
+
+    it('should not call any callbacks after cancel_all', function()
+      local callbacks_triggered = 0
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        local job = create_mock_job()
+        local stored_callback = opts.callback
+        job._trigger_callback = function()
+          if stored_callback then
+            stored_callback({
+              status = 200,
+              body = vim.json.encode({ content = 'test' }),
+            })
+          end
+        end
+        return job
+      end
+
+      local handle1 = http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test1' },
+        on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
+        on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
+      })
+
+      local job1 = mock_job
+
+      local handle2 = http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test2' },
+        on_success = function() callbacks_triggered = callbacks_triggered + 1 end,
+        on_error = function() callbacks_triggered = callbacks_triggered + 1 end,
+      })
+
+      local job2 = mock_job
+
+      -- Cancel all
+      http.cancel_all()
+
+      -- Try to trigger callbacks
+      vim.schedule(function()
+        job1._trigger_callback()
+        job2._trigger_callback()
+      end)
+
+      vim.wait(100, function() return callbacks_triggered > 0 end, 10)
+
+      assert.are.equal(0, callbacks_triggered)
+    end)
+
+    it('should clear the pending requests list', function()
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() end,
+        on_error = function() end,
+      })
+
+      http.cancel_all()
+
+      -- Subsequent cancel_all should not error (list should be empty)
+      assert.has_no.errors(function()
+        http.cancel_all()
+      end)
+    end)
+  end)
+
+  describe('timeout handling', function()
+    it('should call on_error when request times out', function()
+      local error_called = false
+      local received_error = nil
+
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        vim.schedule(function()
+          if opts.callback then
+            opts.callback({
+              status = 0,
+              body = nil,
+              exit = 28, -- curl timeout exit code
+            })
+          end
+        end)
+        return mock_job
+      end
+
+      http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        timeout = 1000,
+        on_success = function() end,
+        on_error = function(err)
+          error_called = true
+          received_error = err
+        end,
+      })
+
+      vim.wait(100, function() return error_called end)
+
+      assert.is_true(error_called)
+      assert.is_not_nil(received_error)
+    end)
+  end)
+
+  describe('request tracking', function()
+    it('should remove completed requests from tracking', function()
+      mock_curl.post = function(opts)
+        mock_curl._last_opts = opts
+        mock_job = create_mock_job()
+        vim.schedule(function()
+          if opts.callback then
+            opts.callback({
+              status = 200,
+              body = vim.json.encode({ content = 'test' }),
+            })
+          end
+        end)
+        return mock_job
+      end
+
+      local completed = false
+      local handle = http.request({
+        endpoint = 'http://localhost:8080/completion',
+        body = { prompt = 'test' },
+        on_success = function() completed = true end,
+        on_error = function() end,
+      })
+
+      vim.wait(100, function() return completed end)
+
+      -- After completion, cancelling should not error
+      assert.has_no.errors(function()
+        http.cancel(handle)
+      end)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/keymaps_spec.lua b/sweep.nvim/tests/sweep/keymaps_spec.lua
new file mode 100644
index 0000000..52eace5
--- /dev/null
+++ b/sweep.nvim/tests/sweep/keymaps_spec.lua
@@ -0,0 +1,413 @@
+-- Tests for sweep.keymaps module
+
+describe('sweep.keymaps', function()
+  local keymaps
+  local config
+  local mock_completion
+  local mock_ui
+  local test_bufnr
+
+  -- Store original keymaps for restoration
+  local original_mappings = {}
+
+  -- Helper to check if a keymap exists
+  local function keymap_exists(mode, lhs, bufnr)
+    local maps = vim.api.nvim_buf_get_keymap(bufnr, mode)
+    for _, map in ipairs(maps) do
+      if map.lhs == lhs then
+        return true
+      end
+    end
+    return false
+  end
+
+  -- Helper to get keymap callback
+  local function get_keymap(mode, lhs, bufnr)
+    local maps = vim.api.nvim_buf_get_keymap(bufnr, mode)
+    for _, map in ipairs(maps) do
+      if map.lhs == lhs then
+        return map
+      end
+    end
+    return nil
+  end
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.keymaps'] = nil
+    package.loaded['sweep.config'] = nil
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.ui'] = nil
+
+    -- Setup config with test keymaps
+    config = require('sweep.config')
+    config.setup({
+      keymaps = {
+        trigger = '',
+        accept_full = '',
+        accept_line = '',
+        accept_word = '',
+        dismiss = '',
+      },
+    })
+
+    -- Create mock completion module
+    mock_completion = {
+      _manual_trigger_called = false,
+      _accept_full_called = false,
+      _accept_line_called = false,
+      _accept_word_called = false,
+      _dismiss_called = false,
+      manual_trigger = function()
+        mock_completion._manual_trigger_called = true
+      end,
+      accept_full = function()
+        mock_completion._accept_full_called = true
+      end,
+      accept_line = function()
+        mock_completion._accept_line_called = true
+      end,
+      accept_word = function()
+        mock_completion._accept_word_called = true
+      end,
+      dismiss = function()
+        mock_completion._dismiss_called = true
+      end,
+    }
+
+    -- Create mock ui module
+    mock_ui = {
+      _visible = false,
+      is_visible = function()
+        return mock_ui._visible
+      end,
+    }
+
+    -- Inject mocks
+    package.loaded['sweep.completion'] = mock_completion
+    package.loaded['sweep.ui'] = mock_ui
+
+    -- Create a test buffer
+    test_bufnr = vim.api.nvim_create_buf(false, true)
+    vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, {
+      'function hello()',
+      '  print("world")',
+      'end',
+    })
+    vim.api.nvim_set_current_buf(test_bufnr)
+
+    keymaps = require('sweep.keymaps')
+  end)
+
+  after_each(function()
+    -- Teardown keymaps
+    pcall(function()
+      keymaps.teardown()
+    end)
+
+    -- Clean up test buffer
+    if vim.api.nvim_buf_is_valid(test_bufnr) then
+      vim.api.nvim_buf_delete(test_bufnr, { force = true })
+    end
+
+    -- Reset package.loaded
+    package.loaded['sweep.keymaps'] = nil
+    package.loaded['sweep.completion'] = nil
+    package.loaded['sweep.ui'] = nil
+  end)
+
+  describe('setup', function()
+    it('should create keymaps for all configured keys', function()
+      keymaps.setup()
+
+      -- Check that keymaps were created (buffer-local)
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+    end)
+
+    it('should use insert mode for all keymaps', function()
+      keymaps.setup()
+
+      -- All keymaps should be in insert mode
+      local cfg = config.get()
+      for name, key in pairs(cfg.keymaps) do
+        local map = get_keymap('i', key, test_bufnr)
+        assert.is_not_nil(map, 'Keymap for ' .. name .. ' (' .. key .. ') should exist')
+      end
+    end)
+
+    it('should respect custom config keymaps', function()
+      -- Update config with custom keymaps
+      package.loaded['sweep.keymaps'] = nil
+      config.setup({
+        keymaps = {
+          trigger = '',
+          accept_full = '',
+          accept_line = '',
+          accept_word = '',
+          dismiss = '',
+        },
+      })
+
+      keymaps = require('sweep.keymaps')
+      keymaps.setup()
+
+      -- Check custom keymaps exist
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+    end)
+
+    it('should be idempotent (multiple calls are safe)', function()
+      keymaps.setup()
+      keymaps.setup()
+      keymaps.setup()
+
+      -- Should still work with keymaps in place
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+    end)
+  end)
+
+  describe('teardown', function()
+    it('should remove all keymaps', function()
+      keymaps.setup()
+
+      -- Verify keymaps exist first
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+
+      keymaps.teardown()
+
+      -- Keymaps should be removed
+      assert.is_false(keymap_exists('i', '', test_bufnr))
+      assert.is_false(keymap_exists('i', '', test_bufnr))
+      assert.is_false(keymap_exists('i', '', test_bufnr))
+      assert.is_false(keymap_exists('i', '', test_bufnr))
+      assert.is_false(keymap_exists('i', '', test_bufnr))
+    end)
+
+    it('should be safe to call without prior setup', function()
+      assert.has_no.errors(function()
+        keymaps.teardown()
+      end)
+    end)
+
+    it('should be safe to call multiple times', function()
+      keymaps.setup()
+      keymaps.teardown()
+      keymaps.teardown()
+      keymaps.teardown()
+      -- No errors should occur
+    end)
+  end)
+
+  describe('trigger keymap', function()
+    it('should call completion.manual_trigger', function()
+      keymaps.setup()
+
+      -- Simulate pressing the trigger key by finding and executing the callback
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      -- For expr mappings, we need to call the callback
+      if map.callback then
+        map.callback()
+      end
+
+      assert.is_true(mock_completion._manual_trigger_called)
+    end)
+  end)
+
+  describe('accept_full keymap', function()
+    it('should call completion.accept_full when visible', function()
+      keymaps.setup()
+      mock_ui._visible = true
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback then
+        map.callback()
+      end
+
+      assert.is_true(mock_completion._accept_full_called)
+    end)
+
+    it('should pass through key when not visible', function()
+      keymaps.setup()
+      mock_ui._visible = false
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      -- For expr mappings, should return the original key when not visible
+      if map.callback and map.expr then
+        local result = map.callback()
+        -- Should return the key to pass through
+        assert.is_not_nil(result)
+        assert.is_truthy(result:match('Tab') or result == '\t')
+      end
+
+      -- Should NOT call accept_full when not visible
+      assert.is_false(mock_completion._accept_full_called)
+    end)
+  end)
+
+  describe('accept_line keymap', function()
+    it('should call completion.accept_line when visible', function()
+      keymaps.setup()
+      mock_ui._visible = true
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback then
+        map.callback()
+      end
+
+      assert.is_true(mock_completion._accept_line_called)
+    end)
+
+    it('should pass through key when not visible', function()
+      keymaps.setup()
+      mock_ui._visible = false
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback and map.expr then
+        local result = map.callback()
+        assert.is_not_nil(result)
+      end
+
+      assert.is_false(mock_completion._accept_line_called)
+    end)
+  end)
+
+  describe('accept_word keymap', function()
+    it('should call completion.accept_word when visible', function()
+      keymaps.setup()
+      mock_ui._visible = true
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback then
+        map.callback()
+      end
+
+      assert.is_true(mock_completion._accept_word_called)
+    end)
+
+    it('should pass through key when not visible', function()
+      keymaps.setup()
+      mock_ui._visible = false
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback and map.expr then
+        local result = map.callback()
+        assert.is_not_nil(result)
+      end
+
+      assert.is_false(mock_completion._accept_word_called)
+    end)
+  end)
+
+  describe('dismiss keymap', function()
+    it('should call completion.dismiss when visible', function()
+      keymaps.setup()
+      mock_ui._visible = true
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback then
+        map.callback()
+      end
+
+      assert.is_true(mock_completion._dismiss_called)
+    end)
+
+    it('should pass through key when not visible', function()
+      keymaps.setup()
+      mock_ui._visible = false
+
+      local map = get_keymap('i', '', test_bufnr)
+      assert.is_not_nil(map)
+
+      if map.callback and map.expr then
+        local result = map.callback()
+        assert.is_not_nil(result)
+      end
+
+      assert.is_false(mock_completion._dismiss_called)
+    end)
+  end)
+
+  describe('expr mappings', function()
+    it('should use expr option for conditional keymaps', function()
+      keymaps.setup()
+
+      -- accept_full, accept_line, accept_word, dismiss should be expr mappings
+      local accept_full_map = get_keymap('i', '', test_bufnr)
+      local accept_line_map = get_keymap('i', '', test_bufnr)
+      local accept_word_map = get_keymap('i', '', test_bufnr)
+      local dismiss_map = get_keymap('i', '', test_bufnr)
+
+      -- These should have expr = 1 (true)
+      assert.are.equal(1, accept_full_map.expr)
+      assert.are.equal(1, accept_line_map.expr)
+      assert.are.equal(1, accept_word_map.expr)
+      assert.are.equal(1, dismiss_map.expr)
+    end)
+
+    it('should return empty string when action is taken', function()
+      keymaps.setup()
+      mock_ui._visible = true
+
+      local map = get_keymap('i', '', test_bufnr)
+      if map.callback and map.expr then
+        local result = map.callback()
+        assert.are.equal('', result)
+      end
+    end)
+  end)
+
+  describe('buffer-local keymaps', function()
+    it('should set keymaps on current buffer', function()
+      keymaps.setup()
+
+      -- Keymaps should exist on test buffer
+      assert.is_true(keymap_exists('i', '', test_bufnr))
+
+      -- Create another buffer and check keymaps don't exist there
+      local other_bufnr = vim.api.nvim_create_buf(false, true)
+      vim.api.nvim_set_current_buf(other_bufnr)
+
+      -- Need to set up for this buffer too
+      assert.is_false(keymap_exists('i', '', other_bufnr))
+
+      -- Clean up
+      vim.api.nvim_set_current_buf(test_bufnr)
+      vim.api.nvim_buf_delete(other_bufnr, { force = true })
+    end)
+  end)
+
+  describe('state tracking', function()
+    it('should track setup state', function()
+      assert.is_false(keymaps.is_setup())
+
+      keymaps.setup()
+      assert.is_true(keymaps.is_setup())
+
+      keymaps.teardown()
+      assert.is_false(keymaps.is_setup())
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/parser_spec.lua b/sweep.nvim/tests/sweep/parser_spec.lua
new file mode 100644
index 0000000..38440e9
--- /dev/null
+++ b/sweep.nvim/tests/sweep/parser_spec.lua
@@ -0,0 +1,332 @@
+-- Tests for sweep.parser module
+
+describe('sweep.parser', function()
+  local parser
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.parser'] = nil
+    parser = require('sweep.parser')
+  end)
+
+  describe('parse', function()
+    it('should parse valid llama.cpp response correctly', function()
+      local response = vim.json.encode({
+        content = 'function hello()',
+        stop = true,
+        tokens_predicted = 45,
+        tokens_evaluated = 120,
+        timings = {
+          prompt_n = 120,
+          prompt_ms = 12.5,
+          predicted_n = 45,
+          predicted_ms = 89.3,
+        },
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.are.equal('function hello()', result.content)
+      assert.are.equal(45, result.tokens_predicted)
+      assert.is_true(result.stopped)
+    end)
+
+    it('should extract content and split into lines', function()
+      local response = vim.json.encode({
+        content = 'line1\nline2\nline3',
+        stop = true,
+        tokens_predicted = 10,
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.are.same({ 'line1', 'line2', 'line3' }, result.lines)
+    end)
+
+    it('should strip stop tokens from end of content', function()
+      local response = vim.json.encode({
+        content = 'some code<|endoftext|>',
+        stop = true,
+        tokens_predicted = 10,
+      })
+
+      local result = parser.parse(response, {
+        stop_tokens = { '<|endoftext|>', '<|file_sep|>' },
+      })
+
+      assert.are.equal('some code', result.content)
+      assert.are.equal('stop_token', result.stop_reason)
+    end)
+
+    it('should handle multiple stop tokens', function()
+      local response = vim.json.encode({
+        content = 'code here\n\n\n',
+        stop = true,
+        tokens_predicted = 10,
+      })
+
+      local result = parser.parse(response, {
+        stop_tokens = { '<|endoftext|>', '\n\n\n' },
+      })
+
+      assert.are.equal('code here', result.content)
+    end)
+
+    it('should trim whitespace when option is enabled', function()
+      local response = vim.json.encode({
+        content = '  hello world  ',
+        stop = true,
+        tokens_predicted = 5,
+      })
+
+      local result = parser.parse(response, {
+        trim_whitespace = true,
+      })
+
+      assert.are.equal('hello world', result.content)
+    end)
+
+    it('should not trim whitespace when option is disabled', function()
+      local response = vim.json.encode({
+        content = '  hello world  ',
+        stop = true,
+        tokens_predicted = 5,
+      })
+
+      local result = parser.parse(response, {
+        trim_whitespace = false,
+      })
+
+      assert.are.equal('  hello world  ', result.content)
+    end)
+
+    it('should handle missing timings gracefully', function()
+      local response = vim.json.encode({
+        content = 'test',
+        stop = true,
+        tokens_predicted = 5,
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.is_not_nil(result.timings)
+      assert.is_nil(result.timings.prompt_ms)
+      assert.is_nil(result.timings.predicted_ms)
+      assert.is_nil(result.timings.tokens_per_second)
+    end)
+
+    it('should handle malformed JSON', function()
+      local response = 'not valid json {'
+
+      local result = parser.parse(response, {})
+
+      assert.are.equal('', result.content)
+      assert.are.same({}, result.lines)
+      assert.is_false(result.stopped)
+      assert.is_not_nil(result.error)
+    end)
+
+    it('should respect max_lines limit', function()
+      local response = vim.json.encode({
+        content = 'line1\nline2\nline3\nline4\nline5',
+        stop = true,
+        tokens_predicted = 20,
+      })
+
+      local result = parser.parse(response, {
+        max_lines = 3,
+      })
+
+      assert.are.equal(3, #result.lines)
+      assert.are.same({ 'line1', 'line2', 'line3' }, result.lines)
+      assert.are.equal('line1\nline2\nline3', result.content)
+    end)
+
+    it('should calculate tokens_per_second correctly', function()
+      local response = vim.json.encode({
+        content = 'test',
+        stop = true,
+        tokens_predicted = 45,
+        timings = {
+          prompt_ms = 100,
+          predicted_n = 45,
+          predicted_ms = 300,  -- 300ms for 45 tokens = 150 tok/s
+        },
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.are.equal(150, result.timings.tokens_per_second)
+    end)
+
+    it('should set stop_reason to length when stop is false', function()
+      local response = vim.json.encode({
+        content = 'partial content',
+        stop = false,
+        tokens_predicted = 100,
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.is_false(result.stopped)
+      assert.are.equal('length', result.stop_reason)
+    end)
+
+    it('should set stop_reason to eos for natural end of sequence', function()
+      local response = vim.json.encode({
+        content = 'complete content',
+        stop = true,
+        tokens_predicted = 10,
+      })
+
+      -- No stop tokens in content, but stop is true = eos
+      local result = parser.parse(response, {
+        stop_tokens = { '<|endoftext|>' },
+      })
+
+      assert.is_true(result.stopped)
+      assert.are.equal('eos', result.stop_reason)
+    end)
+
+    it('should handle empty content', function()
+      local response = vim.json.encode({
+        content = '',
+        stop = true,
+        tokens_predicted = 0,
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.are.equal('', result.content)
+      assert.are.same({ '' }, result.lines)
+    end)
+
+    it('should handle missing content field', function()
+      local response = vim.json.encode({
+        stop = true,
+        tokens_predicted = 0,
+      })
+
+      local result = parser.parse(response, {})
+
+      assert.are.equal('', result.content)
+      assert.are.same({}, result.lines)
+    end)
+  end)
+
+  describe('first_line', function()
+    it('should return only first line', function()
+      local result = {
+        content = 'first line\nsecond line\nthird line',
+        lines = { 'first line', 'second line', 'third line' },
+      }
+
+      assert.are.equal('first line', parser.first_line(result))
+    end)
+
+    it('should return content when only one line', function()
+      local result = {
+        content = 'single line',
+        lines = { 'single line' },
+      }
+
+      assert.are.equal('single line', parser.first_line(result))
+    end)
+
+    it('should return empty string for empty result', function()
+      local result = {
+        content = '',
+        lines = {},
+      }
+
+      assert.are.equal('', parser.first_line(result))
+    end)
+  end)
+
+  describe('first_word', function()
+    it('should return only first word', function()
+      local result = {
+        content = 'hello world foo bar',
+        lines = { 'hello world foo bar' },
+      }
+
+      assert.are.equal('hello', parser.first_word(result))
+    end)
+
+    it('should return entire content if single word', function()
+      local result = {
+        content = 'hello',
+        lines = { 'hello' },
+      }
+
+      assert.are.equal('hello', parser.first_word(result))
+    end)
+
+    it('should return empty string for empty result', function()
+      local result = {
+        content = '',
+        lines = {},
+      }
+
+      assert.are.equal('', parser.first_word(result))
+    end)
+
+    it('should handle leading whitespace', function()
+      local result = {
+        content = '  hello world',
+        lines = { '  hello world' },
+      }
+
+      -- First word should include leading space to maintain indentation
+      assert.are.equal('  hello', parser.first_word(result))
+    end)
+
+    it('should handle word with special characters', function()
+      local result = {
+        content = 'function_name(arg)',
+        lines = { 'function_name(arg)' },
+      }
+
+      -- Word boundary at (
+      assert.are.equal('function_name', parser.first_word(result))
+    end)
+  end)
+
+  describe('is_empty', function()
+    it('should return true for whitespace-only content', function()
+      local result = {
+        content = '   \n\t  \n  ',
+        lines = { '   ', '\t  ', '  ' },
+      }
+
+      assert.is_true(parser.is_empty(result))
+    end)
+
+    it('should return true for empty content', function()
+      local result = {
+        content = '',
+        lines = {},
+      }
+
+      assert.is_true(parser.is_empty(result))
+    end)
+
+    it('should return false for content with text', function()
+      local result = {
+        content = 'hello',
+        lines = { 'hello' },
+      }
+
+      assert.is_false(parser.is_empty(result))
+    end)
+
+    it('should return false for content with leading whitespace and text', function()
+      local result = {
+        content = '  hello  ',
+        lines = { '  hello  ' },
+      }
+
+      assert.is_false(parser.is_empty(result))
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/ring_spec.lua b/sweep.nvim/tests/sweep/ring_spec.lua
new file mode 100644
index 0000000..807bffb
--- /dev/null
+++ b/sweep.nvim/tests/sweep/ring_spec.lua
@@ -0,0 +1,223 @@
+-- Tests for sweep.ring module (ring buffer for cross-file context collection)
+
+describe('sweep.ring', function()
+  local ring
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.ring'] = nil
+    ring = require('sweep.ring')
+    ring.setup({
+      max_chunks = 4,
+      chunk_size = 10,
+    })
+  end)
+
+  describe('setup', function()
+    it('should initialize with given options', function()
+      ring.setup({
+        max_chunks = 8,
+        chunk_size = 32,
+      })
+      local stats = ring.stats()
+      assert.are.equal(0, stats.count)
+      assert.are.equal(8, stats.max)
+    end)
+  end)
+
+  describe('add', function()
+    it('should store chunk with metadata', function()
+      ring.add({
+        content = 'local x = 1',
+        filename = '/path/to/file.lua',
+        filetype = 'lua',
+        source = 'yank',
+      })
+
+      local chunks = ring.get_chunks({})
+      assert.are.equal(1, #chunks)
+      assert.are.equal('local x = 1', chunks[1].content)
+      assert.are.equal('/path/to/file.lua', chunks[1].filename)
+      assert.are.equal('lua', chunks[1].filetype)
+      assert.are.equal('yank', chunks[1].source)
+      assert.is_not_nil(chunks[1].timestamp)
+    end)
+
+    it('should evict oldest chunk when ring buffer is full', function()
+      ring.add({ content = 'chunk1', filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'chunk2', filename = '/b.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'chunk3', filename = '/c.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'chunk4', filename = '/d.lua', filetype = 'lua', source = 'yank' })
+      -- Buffer is now full (max_chunks = 4)
+
+      -- Add one more, should evict chunk1
+      ring.add({ content = 'chunk5', filename = '/e.lua', filetype = 'lua', source = 'yank' })
+
+      local stats = ring.stats()
+      assert.are.equal(4, stats.count)
+
+      local chunks = ring.get_chunks({})
+      -- Should have chunk2, chunk3, chunk4, chunk5 (chunk1 evicted)
+      local contents = {}
+      for _, chunk in ipairs(chunks) do
+        table.insert(contents, chunk.content)
+      end
+      assert.is_nil(vim.tbl_contains(contents, 'chunk1') and 'chunk1' or nil)
+      assert.is_true(vim.tbl_contains(contents, 'chunk5'))
+    end)
+
+    it('should not add duplicate chunks based on first 100 chars', function()
+      local long_content = string.rep('a', 150)
+      ring.add({ content = long_content, filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = long_content, filename = '/a.lua', filetype = 'lua', source = 'save' })
+
+      local stats = ring.stats()
+      assert.are.equal(1, stats.count)
+    end)
+
+    it('should allow similar chunks with different first 100 chars', function()
+      local content1 = 'unique_prefix_1' .. string.rep('a', 100)
+      local content2 = 'unique_prefix_2' .. string.rep('a', 100)
+      ring.add({ content = content1, filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = content2, filename = '/a.lua', filetype = 'lua', source = 'yank' })
+
+      local stats = ring.stats()
+      assert.are.equal(2, stats.count)
+    end)
+
+    it('should trim content to chunk_size lines', function()
+      -- chunk_size is 10 lines
+      local lines = {}
+      for i = 1, 20 do
+        table.insert(lines, 'line ' .. i)
+      end
+      local content = table.concat(lines, '\n')
+
+      ring.add({ content = content, filename = '/a.lua', filetype = 'lua', source = 'yank' })
+
+      local chunks = ring.get_chunks({})
+      local result_lines = vim.split(chunks[1].content, '\n')
+      assert.are.equal(10, #result_lines)
+      assert.are.equal('line 1', result_lines[1])
+      assert.are.equal('line 10', result_lines[10])
+    end)
+  end)
+
+  describe('get_context', function()
+    it('should return formatted context string', function()
+      ring.add({ content = 'local x = 1', filename = '/path/to/file.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'def foo():', filename = '/path/to/script.py', filetype = 'python', source = 'buffer_enter' })
+
+      local context = ring.get_context()
+
+      -- Most recent first
+      assert.is_true(context:find('-- File: /path/to/script.py (python)', 1, true) ~= nil)
+      assert.is_true(context:find('def foo():', 1, true) ~= nil)
+      assert.is_true(context:find('-- File: /path/to/file.lua (lua)', 1, true) ~= nil)
+      assert.is_true(context:find('local x = 1', 1, true) ~= nil)
+    end)
+
+    it('should return empty string when buffer is empty', function()
+      local context = ring.get_context()
+      assert.are.equal('', context)
+    end)
+
+    it('should return most recent chunks first', function()
+      ring.add({ content = 'first', filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'second', filename = '/b.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'third', filename = '/c.lua', filetype = 'lua', source = 'yank' })
+
+      local context = ring.get_context()
+      local third_pos = context:find('third', 1, true)
+      local second_pos = context:find('second', 1, true)
+      local first_pos = context:find('first', 1, true)
+
+      assert.is_true(third_pos < second_pos)
+      assert.is_true(second_pos < first_pos)
+    end)
+  end)
+
+  describe('get_chunks', function()
+    before_each(function()
+      ring.add({ content = 'lua code 1', filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'python code', filename = '/b.py', filetype = 'python', source = 'buffer_enter' })
+      ring.add({ content = 'lua code 2', filename = '/c.lua', filetype = 'lua', source = 'save' })
+    end)
+
+    it('should filter by filetype', function()
+      local chunks = ring.get_chunks({ filetype = 'lua' })
+      assert.are.equal(2, #chunks)
+      for _, chunk in ipairs(chunks) do
+        assert.are.equal('lua', chunk.filetype)
+      end
+    end)
+
+    it('should exclude specified file', function()
+      local chunks = ring.get_chunks({ exclude_file = '/a.lua' })
+      assert.are.equal(2, #chunks)
+      for _, chunk in ipairs(chunks) do
+        assert.is_not.are.equal('/a.lua', chunk.filename)
+      end
+    end)
+
+    it('should respect max_chunks limit', function()
+      local chunks = ring.get_chunks({ max_chunks = 2 })
+      assert.are.equal(2, #chunks)
+    end)
+
+    it('should return most recent chunks first', function()
+      local chunks = ring.get_chunks({})
+      -- Most recent is 'lua code 2'
+      assert.are.equal('lua code 2', chunks[1].content)
+      assert.are.equal('python code', chunks[2].content)
+      assert.are.equal('lua code 1', chunks[3].content)
+    end)
+
+    it('should combine multiple filters', function()
+      local chunks = ring.get_chunks({
+        filetype = 'lua',
+        exclude_file = '/a.lua',
+        max_chunks = 10,
+      })
+      assert.are.equal(1, #chunks)
+      assert.are.equal('lua code 2', chunks[1].content)
+    end)
+
+    it('should return empty table when no chunks match', function()
+      local chunks = ring.get_chunks({ filetype = 'rust' })
+      assert.are.same({}, chunks)
+    end)
+  end)
+
+  describe('clear', function()
+    it('should empty the buffer', function()
+      ring.add({ content = 'chunk1', filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'chunk2', filename = '/b.lua', filetype = 'lua', source = 'yank' })
+
+      ring.clear()
+
+      local stats = ring.stats()
+      assert.are.equal(0, stats.count)
+      assert.are.same({}, ring.get_chunks({}))
+    end)
+  end)
+
+  describe('stats', function()
+    it('should return correct counts', function()
+      ring.add({ content = 'lua1', filename = '/a.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'lua2', filename = '/b.lua', filetype = 'lua', source = 'yank' })
+      ring.add({ content = 'py1', filename = '/c.py', filetype = 'python', source = 'yank' })
+
+      local stats = ring.stats()
+      assert.are.equal(3, stats.count)
+      assert.are.equal(4, stats.max)
+      assert.are.same({ lua = 2, python = 1 }, stats.filetypes)
+    end)
+
+    it('should return empty filetypes when buffer is empty', function()
+      local stats = ring.stats()
+      assert.are.equal(0, stats.count)
+      assert.are.same({}, stats.filetypes)
+    end)
+  end)
+end)
diff --git a/sweep.nvim/tests/sweep/ui_spec.lua b/sweep.nvim/tests/sweep/ui_spec.lua
new file mode 100644
index 0000000..13175d3
--- /dev/null
+++ b/sweep.nvim/tests/sweep/ui_spec.lua
@@ -0,0 +1,379 @@
+-- Tests for sweep.ui module
+
+describe('sweep.ui', function()
+  local ui
+  local config
+  local test_bufnr
+
+  before_each(function()
+    -- Reset module state before each test
+    package.loaded['sweep.ui'] = nil
+    package.loaded['sweep.config'] = nil
+
+    config = require('sweep.config')
+    config.setup()
+    ui = require('sweep.ui')
+
+    -- Create a test buffer with some content
+    test_bufnr = vim.api.nvim_create_buf(false, true)
+    vim.api.nvim_buf_set_lines(test_bufnr, 0, -1, false, {
+      'line 0',
+      'line 1',
+      'line 2',
+      'line 3',
+      'line 4',
+      'line 5',
+      'line 6',
+      'line 7',
+      'line 8',
+      'line 9',
+      'line 10',
+      'line 11',
+    })
+    vim.api.nvim_set_current_buf(test_bufnr)
+  end)
+
+  after_each(function()
+    -- Clean up test buffer
+    ui.clear()
+    if vim.api.nvim_buf_is_valid(test_bufnr) then
+      vim.api.nvim_buf_delete(test_bufnr, { force = true })
+    end
+  end)
+
+  describe('show', function()
+    it('should create extmarks at correct position', function()
+      ui.show({
+        lines = { 'completion text' },
+        bufnr = test_bufnr,
+        row = 5,
+        col = 4,
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      assert.is_true(#marks > 0)
+      -- Check mark is at correct row (0-indexed)
+      assert.are.equal(5, marks[1][2])
+    end)
+
+    it('should use virt_text for single-line completion', function()
+      ui.show({
+        lines = { 'single line' },
+        bufnr = test_bufnr,
+        row = 3,
+        col = 4,
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      assert.is_true(#marks > 0)
+      local details = marks[1][4]
+      assert.is_not_nil(details.virt_text)
+      -- Should not have virt_lines for single-line completion
+      assert.is_nil(details.virt_lines)
+    end)
+
+    it('should use virt_text and virt_lines for multi-line completion', function()
+      ui.show({
+        lines = { 'first line', 'second line', 'third line' },
+        bufnr = test_bufnr,
+        row = 2,
+        col = 4,
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      assert.is_true(#marks > 0)
+      local details = marks[1][4]
+      -- First line should be in virt_text
+      assert.is_not_nil(details.virt_text)
+      -- Additional lines should be in virt_lines
+      assert.is_not_nil(details.virt_lines)
+      assert.are.equal(2, #details.virt_lines)
+    end)
+
+    it('should use correct highlight group from config', function()
+      ui.show({
+        lines = { 'highlighted text' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 0,
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      assert.is_true(#marks > 0)
+      local details = marks[1][4]
+      local hl_group = details.virt_text[1][2]
+      assert.are.equal('SweepGhostText', hl_group)
+    end)
+
+    it('should clear previous marks on consecutive calls', function()
+      ui.show({
+        lines = { 'first completion' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 0,
+      })
+
+      ui.show({
+        lines = { 'second completion' },
+        bufnr = test_bufnr,
+        row = 3,
+        col = 0,
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      -- Should only have marks from second call
+      assert.are.equal(1, #marks)
+      -- Mark should be at row 3
+      assert.are.equal(3, marks[1][2])
+    end)
+
+    it('should handle empty lines array gracefully', function()
+      -- Should not error
+      ui.show({
+        lines = {},
+        bufnr = test_bufnr,
+        row = 0,
+        col = 0,
+      })
+
+      assert.is_false(ui.is_visible())
+    end)
+
+    it('should display info when provided', function()
+      ui.show({
+        lines = { 'completion' },
+        bufnr = test_bufnr,
+        row = 2,
+        col = 4,
+        info = {
+          tokens = 45,
+          latency_ms = 89,
+        },
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      assert.is_true(#marks > 0)
+      local details = marks[1][4]
+      -- Info should be part of the virt_text array
+      assert.is_true(#details.virt_text >= 2)
+      -- Second element should have info highlight group
+      local info_hl = details.virt_text[2][2]
+      assert.are.equal('SweepInfo', info_hl)
+    end)
+
+    it('should not display info when show_info is false', function()
+      config.setup({ ui = { show_info = false } })
+      -- Reload ui module to pick up new config
+      package.loaded['sweep.ui'] = nil
+      ui = require('sweep.ui')
+
+      ui.show({
+        lines = { 'completion' },
+        bufnr = test_bufnr,
+        row = 2,
+        col = 4,
+        info = {
+          tokens = 45,
+          latency_ms = 89,
+        },
+      })
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+
+      assert.is_true(#marks > 0)
+      local details = marks[1][4]
+      -- Should only have one virt_text entry (no info)
+      assert.are.equal(1, #details.virt_text)
+    end)
+  end)
+
+  describe('clear', function()
+    it('should remove all extmarks in namespace', function()
+      ui.show({
+        lines = { 'completion text' },
+        bufnr = test_bufnr,
+        row = 5,
+        col = 4,
+      })
+
+      ui.clear()
+
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, {})
+
+      assert.are.equal(0, #marks)
+    end)
+
+    it('should be safe to call when no completion is visible', function()
+      -- Should not error
+      ui.clear()
+      ui.clear()
+      assert.is_false(ui.is_visible())
+    end)
+  end)
+
+  describe('is_visible', function()
+    it('should return true when completion is visible', function()
+      ui.show({
+        lines = { 'completion' },
+        bufnr = test_bufnr,
+        row = 0,
+        col = 0,
+      })
+
+      assert.is_true(ui.is_visible())
+    end)
+
+    it('should return false when no completion is visible', function()
+      assert.is_false(ui.is_visible())
+    end)
+
+    it('should return false after clear is called', function()
+      ui.show({
+        lines = { 'completion' },
+        bufnr = test_bufnr,
+        row = 0,
+        col = 0,
+      })
+
+      ui.clear()
+
+      assert.is_false(ui.is_visible())
+    end)
+  end)
+
+  describe('get_current', function()
+    it('should return completion data when visible', function()
+      ui.show({
+        lines = { 'line 1', 'line 2' },
+        bufnr = test_bufnr,
+        row = 5,
+        col = 10,
+      })
+
+      local current = ui.get_current()
+
+      assert.is_not_nil(current)
+      assert.are.same({ 'line 1', 'line 2' }, current.lines)
+      assert.are.equal(test_bufnr, current.bufnr)
+      assert.are.equal(5, current.row)
+      assert.are.equal(10, current.col)
+    end)
+
+    it('should return nil when not visible', function()
+      local current = ui.get_current()
+      assert.is_nil(current)
+    end)
+
+    it('should return nil after clear is called', function()
+      ui.show({
+        lines = { 'completion' },
+        bufnr = test_bufnr,
+        row = 0,
+        col = 0,
+      })
+
+      ui.clear()
+
+      local current = ui.get_current()
+      assert.is_nil(current)
+    end)
+
+    it('should return updated data after consecutive shows', function()
+      ui.show({
+        lines = { 'first' },
+        bufnr = test_bufnr,
+        row = 1,
+        col = 1,
+      })
+
+      ui.show({
+        lines = { 'second', 'third' },
+        bufnr = test_bufnr,
+        row = 5,
+        col = 8,
+      })
+
+      local current = ui.get_current()
+
+      assert.is_not_nil(current)
+      assert.are.same({ 'second', 'third' }, current.lines)
+      assert.are.equal(5, current.row)
+      assert.are.equal(8, current.col)
+    end)
+  end)
+
+  describe('edge cases', function()
+    it('should handle cursor at end of line', function()
+      -- Line 0 is "line 0" which has 6 characters
+      ui.show({
+        lines = { 'appended' },
+        bufnr = test_bufnr,
+        row = 0,
+        col = 6,  -- End of "line 0"
+      })
+
+      assert.is_true(ui.is_visible())
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+      assert.is_true(#marks > 0)
+    end)
+
+    it('should handle completion with empty first line', function()
+      ui.show({
+        lines = { '', 'second line' },
+        bufnr = test_bufnr,
+        row = 3,
+        col = 4,
+      })
+
+      assert.is_true(ui.is_visible())
+      local current = ui.get_current()
+      assert.are.same({ '', 'second line' }, current.lines)
+    end)
+
+    it('should handle multi-line completion with trailing empty lines', function()
+      ui.show({
+        lines = { 'content', '', '' },
+        bufnr = test_bufnr,
+        row = 2,
+        col = 0,
+      })
+
+      assert.is_true(ui.is_visible())
+      local ns = vim.api.nvim_create_namespace('sweep')
+      local marks = vim.api.nvim_buf_get_extmarks(test_bufnr, ns, 0, -1, { details = true })
+      local details = marks[1][4]
+      -- Should have virt_lines for the empty lines
+      assert.is_not_nil(details.virt_lines)
+      assert.are.equal(2, #details.virt_lines)
+    end)
+
+    it('should handle using bufnr 0 for current buffer', function()
+      ui.show({
+        lines = { 'completion' },
+        bufnr = 0,
+        row = 1,
+        col = 0,
+      })
+
+      assert.is_true(ui.is_visible())
+      local current = ui.get_current()
+      -- get_current should return the resolved buffer number, not 0
+      assert.are.equal(test_bufnr, current.bufnr)
+    end)
+  end)
+end)