├── stylua.toml ├── LICENSE ├── README.md └── lua └── contextpilot.lua /stylua.toml: -------------------------------------------------------------------------------- 1 | column_width = 100 2 | indent_type = "Spaces" 3 | indent_width = 2 4 | collapse_simple_statement = 'Always' 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Custom License 2 | 3 | Copyright (c) 2024-2025 KUSHASHWA RAVI SHRIMALI 4 | 5 | Permission is hereby granted to any individual or organization to use and modify this software for personal, educational, or internal purposes, subject to the following conditions: 6 | 7 | 1. **No Distribution** 8 | Redistribution of this software, whether in original or modified form, is strictly prohibited without prior written permission from the author. 9 | 10 | 2. **No Warranty** 11 | This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. In no event shall the author be liable for any claim, damages, or other liability arising from the use of this software. 12 | 13 | For inquiries, contact: kushashwaravishrimali@gmail.com 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ContextPilot Plugin for NeoVim 2 | 3 | ContextPilot helps you quickly find contextually relevant files based on your current file, line, or selection in Neovim. It leverages fuzzy searching and indexing to improve your workflow. 4 | 5 | --- 6 | 7 | ## 📦 Installation 8 | 9 | ### Using **lazy.nvim**: 10 | 11 | ```lua 12 | { 13 | "krshrimali/context-pilot.nvim", 14 | dependencies = { 15 | "nvim-telescope/telescope.nvim", 16 | "nvim-telescope/telescope-fzy-native.nvim" 17 | }, 18 | config = function() 19 | require("contextpilot") 20 | end 21 | } 22 | ``` 23 | 24 | --- 25 | 26 | ## ⚙️ Pre-requisites 27 | 28 | Install the ContextPilot server: 29 | 30 | ```bash 31 | brew install krshrimali/context-pilot/context-pilot 32 | ``` 33 | 34 | OR if using AUR, refer: https://aur.archlinux.org/packages/contextpilot. 35 | 36 | In case you are not using either of the package managers above, follow the commands below: (`cargo` installation is must) 37 | 38 | ```bash 39 | git clone https://github.com/krshrimali/context-pilot-rs && cd context-pilot-rs 40 | cargo build --release 41 | cp ./target/release/contextpilot ~/.local/bin/ 42 | ``` 43 | 44 | Feel free to replace the binary path to `/usr/local/bin` based on your system. 45 | 46 | --- 47 | 48 | ## 🚀 Getting Started 49 | 50 | 1. (Optional, for faster query results) Start indexing your workspace from Neovim: 51 | 52 | ```lua 53 | :ContextPilotStartIndexing 54 | ``` 55 | 56 | 2. (Optional, for faster query results) OR Index some selected repositories: 57 | 58 | ```lua 59 | :ContextPilotIndexSubDirectory 60 | ``` 61 | 62 | Choose the subdirectories you want to index (hitting `Tab`) and let the indexing finish. 63 | 64 | 2. Use any of the following commands to retrieve relevant files: 65 | 66 | - `:ContextPilotRelevantCommitsRange` - Fetch relevant commits for **selected range** of lines. 67 | - `:ContextPilotRelevantFilesWholeFile` — Fetch contextually relevant files for the **current file**. 68 | - `:ContextPilotRelevantFilesRange` — Fetch relevant files for a **selected range** of lines. 69 | 70 | --- 71 | 72 | ## 📚 Tips 73 | 74 | - Re-index your project whenever significant codebase changes occur. 75 | -------------------------------------------------------------------------------- /lua/contextpilot.lua: -------------------------------------------------------------------------------- 1 | -- Main module table that will be returned at the end of the file 2 | local A = {} 3 | 4 | -- The command name for the external contextpilot binary 5 | A.command = "contextpilot" 6 | -- Stores the current operation title for display purposes 7 | A.current_title = "" 8 | -- Table to store parsed results from contextpilot command execution 9 | A.autorun_data = {} 10 | -- Table to store description/commit data from contextpilot desc queries 11 | A.desc_picker = {} 12 | 13 | -- Import required telescope modules for creating interactive pickers 14 | local telescope_pickers = require("telescope.pickers") 15 | local finders = require("telescope.finders") 16 | local sorters = require("telescope.sorters") 17 | 18 | -- Check if telescope is available, exit early if not found 19 | if not pcall(require, "telescope") then 20 | print("Telescope plugin not found") 21 | return 22 | end 23 | -- Load the fzy_native extension for better fuzzy searching 24 | require("telescope").load_extension("fzy_native") 25 | 26 | -- Helper function to display notifications to the user 27 | local notify_inform = function(msg, level) 28 | -- Use vim's built-in notification system with default INFO level 29 | vim.api.nvim_notify(msg, level or vim.log.levels.INFO, {}) 30 | end 31 | 32 | -- Minimum required version of contextpilot binary for compatibility 33 | local MIN_CONTEXTPILOT_VERSION = "0.9.0" 34 | 35 | -- Parse a semantic version string (e.g., "1.2.3") into individual numeric components 36 | local function parse_version(version_str) 37 | -- Use pattern matching to extract major, minor, and patch numbers 38 | local major, minor, patch = version_str:match("(%d+)%.(%d+)%.(%d+)") 39 | -- Convert string matches to numbers for comparison 40 | return tonumber(major), tonumber(minor), tonumber(patch) 41 | end 42 | 43 | -- Compare two semantic versions to check if installed version meets requirements 44 | local function is_version_compatible(installed, required) 45 | -- Parse both version strings into numeric components 46 | local imaj, imin, ipat = parse_version(installed) 47 | local rmaj, rmin, rpat = parse_version(required) 48 | -- Compare major version first (must be greater or equal) 49 | if imaj ~= rmaj then return imaj > rmaj end 50 | -- If major versions match, compare minor version 51 | if imin ~= rmin then return imin > rmin end 52 | -- If major and minor match, patch version must be greater or equal 53 | return ipat >= rpat 54 | end 55 | 56 | -- Verify that the contextpilot binary is installed and meets minimum version requirements 57 | local function check_contextpilot_version() 58 | -- Ensure a readable message is displayed if contextpilot is not installed and the 59 | -- minimum version is not met. (See `MIN_CONTEXTPILOT_VERSION` above.) 60 | -- Execute contextpilot --version command and capture output 61 | local output = vim.fn.system(A.command .. " --version") 62 | -- Check if command failed or produced no output 63 | if vim.v.shell_error ~= 0 or not output or output == "" then 64 | notify_inform("❌ Unable to determine contextpilot version.", vim.log.levels.ERROR) 65 | return false 66 | end 67 | 68 | -- Extract version number from command output using pattern matching 69 | local version = output:match("contextpilot%s+(%d+%.%d+%.%d+)") 70 | if not version then 71 | notify_inform("⚠️ Unexpected version output: " .. output, vim.log.levels.ERROR) 72 | return false 73 | end 74 | 75 | -- Check if the installed version meets our minimum requirements 76 | if not is_version_compatible(version, MIN_CONTEXTPILOT_VERSION) then 77 | notify_inform( 78 | string.format( 79 | "🚨 Your contextpilot version is %s. Please update to at least version %s.", 80 | version, 81 | MIN_CONTEXTPILOT_VERSION 82 | ), 83 | vim.log.levels.WARN 84 | ) 85 | return false 86 | end 87 | 88 | -- Version check passed 89 | return true 90 | end 91 | 92 | -- Import telescope action modules for handling user interactions 93 | local actions = require("telescope.actions") 94 | local action_state = require("telescope.actions.state") 95 | 96 | -- Spinner UI state variables 97 | -- Array of Unicode Braille characters that create a spinning animation effect 98 | local spinner_frames = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } 99 | -- Current frame index in the spinner animation (1-based) 100 | local spinner_index = 1 101 | -- Variables to track floating window components: window handle, buffer handle, timer 102 | local progress_win, progress_buf, progress_timer 103 | -- List to store file paths that have been processed during indexing 104 | local extracted_files = {} 105 | 106 | -- Create a floating window for displaying indexing progress 107 | local function create_floating_window() 108 | -- Create a new buffer that is not listed and will be deleted when window closes 109 | progress_buf = vim.api.nvim_create_buf(false, true) 110 | -- Configure window options for the floating window 111 | local win_opts = { 112 | relative = "editor", -- Position relative to the editor 113 | width = 60, -- Window width in columns 114 | height = 6, -- Window height in rows 115 | col = vim.o.columns - 62, -- Position 62 columns from the right edge 116 | row = vim.o.lines - 6, -- Position 6 rows from the bottom 117 | style = "minimal", -- Remove UI elements like line numbers 118 | border = "rounded", -- Use rounded border style 119 | } 120 | -- Create and open the floating window with the specified buffer and options 121 | progress_win = vim.api.nvim_open_win(progress_buf, true, win_opts) 122 | end 123 | 124 | -- Update the content of the floating progress window 125 | local function update_floating_window(text) 126 | -- Check if buffer exists and is still valid before updating 127 | if progress_buf and vim.api.nvim_buf_is_valid(progress_buf) then 128 | -- Set the complete content of the buffer with progress information 129 | vim.api.nvim_buf_set_lines(progress_buf, 0, -1, false, { 130 | "📦 Indexing Workspace...", -- Header with package emoji 131 | "", -- Empty line for spacing 132 | text or "", -- Dynamic text (spinner + status) or empty string 133 | "", -- Empty line for spacing 134 | "Press to close this message", -- User instruction 135 | }) 136 | end 137 | end 138 | 139 | -- Timer variable for the minimal spinner animation 140 | local spinner_timer 141 | 142 | -- Start a minimal spinner animation in the command line area 143 | local function start_spinner_minimal(msg) 144 | -- Reset the spinner frame to the first frame. 145 | spinner_index = 1 146 | -- Create a new libuv timer object for animation: 147 | spinner_timer = vim.loop.new_timer() 148 | -- Start the timer with specified intervals and callback 149 | spinner_timer:start( 150 | 0, -- start immediately (no initial delay) 151 | 120, -- repeat every 120ms for smooth animation 152 | -- Run this function on each tick: 153 | vim.schedule_wrap(function() 154 | -- Get the current spinner character from the frames array 155 | local spinner = spinner_frames[spinner_index] 156 | -- Advance to next frame, wrapping back to 1 after the last frame 157 | spinner_index = (spinner_index % #spinner_frames) + 1 158 | -- Display spinner character and message in Neovim's command line 159 | vim.api.nvim_echo({ { spinner .. " " .. msg, "None" } }, false, {}) 160 | end) 161 | ) 162 | end 163 | 164 | -- Stop the minimal spinner animation and display a final message 165 | local function stop_spinner_minimal(final_msg) 166 | -- Check if timer exists before trying to stop it 167 | if spinner_timer then 168 | -- Stop the timer from firing additional callbacks 169 | spinner_timer:stop() 170 | -- Close and cleanup the timer resources 171 | spinner_timer:close() 172 | -- Clear the timer reference 173 | spinner_timer = nil 174 | end 175 | -- Display the final message in the command line, replacing the spinner 176 | vim.api.nvim_echo({ { final_msg, "None" } }, false, {}) 177 | end 178 | 179 | -- Start the full-featured spinner with floating window for indexing operations 180 | local function start_spinner() 181 | -- Reset spinner animation to first frame 182 | spinner_index = 1 183 | -- Clear the list of extracted files from previous operations 184 | extracted_files = {} 185 | -- Create and display the floating progress window 186 | create_floating_window() 187 | -- Create a new timer for the floating window spinner animation 188 | progress_timer = vim.loop.new_timer() 189 | -- Start the timer with faster update interval than minimal spinner 190 | progress_timer:start( 191 | 0, -- start immediately 192 | 100, -- update every 100ms for smoother animation 193 | vim.schedule_wrap(function() 194 | -- Only update if the progress buffer is still valid 195 | if progress_buf and vim.api.nvim_buf_is_valid(progress_buf) then 196 | -- Get current spinner frame 197 | local spinner = spinner_frames[spinner_index] 198 | -- Advance to next frame 199 | spinner_index = (spinner_index % #spinner_frames) + 1 200 | -- Update floating window with spinner and file count 201 | update_floating_window(spinner .. " Files indexed: " .. tostring(#extracted_files)) 202 | end 203 | end) 204 | ) 205 | end 206 | 207 | -- Stop the floating window spinner and show completion message 208 | local function stop_spinner() 209 | -- Stop and cleanup the progress timer if it exists 210 | if progress_timer then 211 | progress_timer:stop() 212 | progress_timer:close() 213 | progress_timer = nil 214 | end 215 | -- Update the floating window with completion message and final file count 216 | update_floating_window("✅ Indexing complete! Total files: " .. tostring(#extracted_files)) 217 | -- Schedule the floating window to close after 2 seconds 218 | vim.defer_fn(function() 219 | -- Check if window is still valid before attempting to close 220 | if progress_win and vim.api.nvim_win_is_valid(progress_win) then 221 | -- Close the floating window and force close even if modified 222 | vim.api.nvim_win_close(progress_win, true) 223 | end 224 | end, 2000) -- 2000ms = 2 seconds delay 225 | end 226 | 227 | -- Create a telescope picker to display and select from contextpilot query results 228 | local function telescope_picker(title) 229 | -- Create a new telescope picker instance 230 | telescope_pickers 231 | .new({}, { 232 | -- Set the title displayed at the top of the picker 233 | prompt_title = "ContextPilot Output: " .. title, 234 | -- Use fuzzy sorter for filtering results as user types 235 | sorter = sorters.get_fzy_sorter(), 236 | -- Configure the finder to process our results data 237 | finder = finders.new_table({ 238 | -- Use the autorun_data table populated by contextpilot command 239 | results = A.autorun_data, 240 | -- Function to transform each result into a telescope entry 241 | entry_maker = function(entry) 242 | -- Parse the entry string to extract filepath and occurrence count 243 | local filepath, count = entry:match("^(.-)%s+%((%d+)%s+occurrences%)$") 244 | -- If parsing fails, use the entire entry as filepath with 0 occurrences 245 | if not filepath then 246 | filepath = entry 247 | count = "0" 248 | end 249 | -- Return telescope entry structure 250 | return { 251 | value = entry, -- Original entry string 252 | ordinal = filepath, -- String used for fuzzy matching 253 | display = string.format("%-60s %s occurrences", filepath, count), -- Display format 254 | path = filepath, -- File path for opening 255 | } 256 | end, 257 | }), 258 | -- Configure key mappings for the picker 259 | attach_mappings = function(prompt_bufnr, _) 260 | -- Replace the default action 261 | actions.select_default:replace(function() 262 | -- Close the picker 263 | actions.close(prompt_bufnr) 264 | -- Get the currently selected entry 265 | local selection = action_state.get_selected_entry() 266 | -- Open the selected file if it has a valid path 267 | if selection and selection.path then 268 | -- Use fnameescape to handle filenames with special characters 269 | vim.cmd("edit " .. vim.fn.fnameescape(selection.path)) 270 | else 271 | vim.api.nvim_notify("No path to open", vim.log.levels.WARN, {}) 272 | end 273 | end) 274 | -- Return true to indicate mappings were successfully attached 275 | return true 276 | end, 277 | }) 278 | -- Start the picker and display it to the user 279 | :find() 280 | end 281 | 282 | -- Process stdout data from contextpilot command and extract relevant information 283 | local function append_data(_, data) 284 | -- Exit early if no data received 285 | if not data then return end 286 | -- Process each line of output from the contextpilot command 287 | for _, line in ipairs(data) do 288 | -- Remove carriage return characters for consistent line endings 289 | line = line:gsub("\r", "") 290 | -- Look for lines indicating file extraction (for indexing progress) 291 | local extracted_path = line:match("^Extracted details for file:%s+(.-)$") 292 | if extracted_path then table.insert(extracted_files, extracted_path) end 293 | -- Look for lines with occurrence counts (for query results) 294 | local file_path, count = line:match("^(.-)%s+%-+%s+(%d+)%s+occurrences$") 295 | if file_path and count then 296 | -- Store the file path and occurrence count as a structured entry 297 | table.insert(A.autorun_data, { path = file_path, count = tonumber(count) }) 298 | end 299 | end 300 | end 301 | 302 | -- Build the contextpilot command string based on operation mode and parameters 303 | local function build_command(file_path, folder_path, start, end_, mode) 304 | -- For index mode, only specify the folder path and mode (no file-specific parameters) 305 | if mode == "index" then return string.format("%s %s -t %s", A.command, folder_path, mode) end 306 | -- For other modes (query, desc), include file path and line range parameters 307 | return string.format( 308 | "%s %s -t %s %s -s %d -e %d", -- contextpilot folder -t mode file -s start -e end 309 | A.command, -- The contextpilot binary name 310 | folder_path, -- Working directory path 311 | mode, -- Operation mode (query, desc, etc.) 312 | file_path, -- Target file path 313 | start, -- Starting line number 314 | end_ -- Ending line number (end_ to avoid Lua keyword conflict) 315 | ) 316 | end 317 | 318 | -- Execute a contextpilot command with specified parameters and handle the results 319 | local function execute_context_pilot(file_path, folder_path, start, end_, mode, title) 320 | -- Clear previous results and set the current operation title 321 | A.autorun_data = {} 322 | A.current_title = title 323 | 324 | -- Build the command string using provided parameters 325 | local command = build_command(file_path, folder_path, start, end_, mode) 326 | 327 | -- Choose appropriate spinner based on operation mode 328 | if mode == "query" then 329 | -- If the mode is "query", we use a minimal spinner for faster operations 330 | start_spinner_minimal("Processing query...") 331 | else 332 | -- For other modes (like indexing), use the full floating window spinner 333 | start_spinner() 334 | end 335 | 336 | -- Start the contextpilot command as an asynchronous job 337 | vim.fn.jobstart(command, { 338 | stdout_buffered = false, -- Process output line by line as it comes 339 | stderr_buffered = true, -- Buffer stderr for error handling 340 | pty = false, -- Don't allocate a pseudo-terminal 341 | on_stdout = append_data, -- Process each line of stdout 342 | on_exit = function(_, exit_code) 343 | -- Stop the appropriate spinner based on mode 344 | if mode == "query" then 345 | stop_spinner_minimal("✅ Query complete!") 346 | else 347 | stop_spinner() 348 | end 349 | 350 | -- Handle command execution results 351 | if exit_code ~= 0 then 352 | -- Command failed, show error message 353 | notify_inform("Error: Command exited with code " .. exit_code, vim.log.levels.ERROR) 354 | elseif #A.autorun_data > 0 and mode ~= "index" then 355 | -- Command succeeded and returned results (not for index mode) 356 | -- Sort by occurrence count in descending order (most relevant first) 357 | table.sort(A.autorun_data, function(a, b) return a.count > b.count end) 358 | 359 | -- Convert structured data back to display strings for telescope 360 | for i, entry in ipairs(A.autorun_data) do 361 | A.autorun_data[i] = string.format("%s (%d occurrences)", entry.path, entry.count) 362 | end 363 | 364 | -- Show results in telescope picker 365 | telescope_picker(A.current_title) 366 | end 367 | end, 368 | }) 369 | end 370 | 371 | -- Public API: Get the most relevant files for the entire current file 372 | function A.get_topn_contexts() 373 | -- Ensure contextpilot binary is available and compatible 374 | if not check_contextpilot_version() then return end 375 | -- Get the current file path and working directory and do some validations: 376 | local file_path = vim.api.nvim_buf_get_name(0) -- Path of currently open buffer 377 | local folder_path = vim.loop.cwd() -- Current working directory 378 | -- Just some extra precautions. 379 | if file_path == "" then 380 | notify_inform("No file is currently open.", vim.log.levels.WARN) 381 | return 382 | end 383 | if not vim.fn.filereadable(file_path) then 384 | notify_inform("File does not exist: " .. file_path, vim.log.levels.ERROR) 385 | return 386 | end 387 | -- Execute contextpilot query for the entire file (line 1 to 0 means whole file) 388 | execute_context_pilot(file_path, folder_path, 1, 0, "query", "Top Files for whole file") 389 | end 390 | 391 | -- Public API: Get the most relevant files for a specific line range in the current file 392 | function A.get_topn_contexts_range(start_line, end_line) 393 | -- Ensure contextpilot binary is available and compatible 394 | if not check_contextpilot_version() then return end 395 | -- Get current file and directory paths 396 | local file_path = vim.api.nvim_buf_get_name(0) 397 | local folder_path = vim.loop.cwd() 398 | -- Create descriptive title for the operation 399 | local title = string.format("Top Files for range (%d, %d)", start_line, end_line) 400 | -- Execute contextpilot query for the specified line range 401 | execute_context_pilot(file_path, folder_path, start_line, end_line, "query", title) 402 | end 403 | 404 | -- Public API: Get the most relevant files for the current cursor line 405 | function A.get_topn_contexts_current_line() 406 | -- Ensure contextpilot binary is available and compatible 407 | if not check_contextpilot_version() then return end 408 | -- Get the current cursor line number (1-based) 409 | local row = vim.api.nvim_win_get_cursor(0)[1] 410 | -- Get current file and directory paths 411 | local file_path = vim.api.nvim_buf_get_name(0) 412 | local folder_path = vim.loop.cwd() 413 | -- Create descriptive title for the operation 414 | local title = "Top Files for current line " .. row 415 | -- Execute contextpilot query for just the current line 416 | execute_context_pilot(file_path, folder_path, row, row, "query", title) 417 | end 418 | 419 | -- Public API: Query contextual information for a specific line range 420 | function A.query_context_for_range(start_line, end_line) 421 | -- Ensure contextpilot binary is available and compatible 422 | if not check_contextpilot_version() then return end 423 | -- Get current file and directory paths 424 | local file_path = vim.api.nvim_buf_get_name(0) 425 | local folder_path = vim.loop.cwd() 426 | -- Create descriptive title for the operation 427 | local title = string.format("Queried Contexts (%d-%d)", start_line, end_line) 428 | -- Execute contextpilot query for the specified line range 429 | execute_context_pilot(file_path, folder_path, start_line, end_line, "query", title) 430 | end 431 | 432 | -- Public API: Start indexing the entire workspace 433 | function A.start_indexing() 434 | -- Ensure contextpilot binary is available and compatible 435 | if not check_contextpilot_version() then return end 436 | -- Get current working directory to index 437 | local folder_path = vim.loop.cwd() 438 | -- Execute contextpilot indexing (empty file_path and 0,0 range for whole workspace) 439 | execute_context_pilot("", folder_path, 0, 0, "index", "Start Indexing your Workspace") 440 | end 441 | 442 | -- Additional telescope imports for description functionality 443 | local telescope_pickers = require("telescope.pickers") 444 | local finders = require("telescope.finders") 445 | local previewers = require("telescope.previewers") 446 | local sorters = require("telescope.sorters") 447 | local actions = require("telescope.actions") 448 | local action_state = require("telescope.actions.state") 449 | -- JSON parsing functionality for description data 450 | local json = vim.json 451 | 452 | -- Process JSON output from contextpilot desc command for commit descriptions 453 | local function append_desc_data(_, data) 454 | -- Exit early if no data received 455 | if not data then return end 456 | -- Concatenate all data lines into a single string 457 | local raw = table.concat(data, "\n") 458 | -- Skip processing if the result is empty or contains only whitespace 459 | if not raw or raw:match("^%s*$") then return end 460 | 461 | -- Attempt to parse the JSON output from contextpilot 462 | local ok, parsed = pcall(vim.json.decode, raw) 463 | if ok and type(parsed) == "table" then 464 | -- Store the parsed description data for use in telescope picker 465 | A.desc_data = parsed 466 | else 467 | -- Notify user if JSON parsing failed 468 | vim.api.nvim_notify("Failed to parse contextpilot desc JSON output", vim.log.levels.ERROR, {}) 469 | end 470 | end 471 | 472 | -- Parse a date string into a Unix timestamp for sorting purposes 473 | local function parse_date_str(date_str) 474 | -- Assumes format like: "Fri May 17 15:44:01 2024" 475 | -- Pattern to extract: (weekday) (month) (day) (hour):(minute):(second) (year) 476 | local pattern = "(%a+)%s+(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+(%d+)" 477 | local _, _, _, month_str, day, hour, min, sec, year = date_str:find(pattern) 478 | 479 | -- Return 0 if parsing failed (will sort to beginning) 480 | if not year then return 0 end 481 | 482 | -- Map month abbreviations to numeric values 483 | local month_map = { 484 | Jan = 1, Feb = 2, Mar = 3, Apr = 4, 485 | May = 5, Jun = 6, Jul = 7, Aug = 8, 486 | Sep = 9, Oct = 10, Nov = 11, Dec = 12, 487 | } 488 | -- Get numeric month, defaulting to 1 if not found 489 | local month = month_map[month_str] or 1 490 | 491 | -- Convert to Unix timestamp using os.time 492 | return os.time({ 493 | year = tonumber(year), 494 | month = tonumber(month), 495 | day = tonumber(day), 496 | hour = tonumber(hour), 497 | min = tonumber(min), 498 | sec = tonumber(sec), 499 | }) or 0 -- Return 0 if os.time fails 500 | end 501 | 502 | local function telescope_desc_picker(title) 503 | notify_inform("Sorted by Date (newest first)", vim.log.levels.INFO) 504 | 505 | -- Sort by parsed datetime descending 506 | table.sort( 507 | A.desc_data, 508 | function(a, b) return parse_date_str(a[4] or "") > parse_date_str(b[4] or "") end 509 | ) 510 | 511 | telescope_pickers 512 | .new({}, { 513 | prompt_title = "ContextPilot Descriptions: " .. title, 514 | finder = finders.new_table({ 515 | results = A.desc_data, 516 | entry_maker = function(entry) 517 | return { 518 | value = entry, 519 | ordinal = (entry[1] or "") .. " " .. (entry[3] or "") .. " " .. (entry[4] or ""), 520 | display = entry[1] or "(no title)", 521 | title = entry[1] or "", 522 | desc = entry[2] or "", 523 | author = entry[3] or "", 524 | date = entry[4] or "", 525 | commitUrl = entry[5] or "", 526 | } 527 | end, 528 | }), 529 | sorter = sorters.get_fzy_sorter(), 530 | previewer = previewers.new_buffer_previewer({ 531 | define_preview = function(self, entry) 532 | local lines = {} 533 | table.insert(lines, "Title: " .. (entry.title or "")) 534 | table.insert(lines, "Author: " .. (entry.author or "")) 535 | table.insert(lines, "Date: " .. (entry.date or "")) 536 | table.insert(lines, "") 537 | table.insert(lines, "Description:") 538 | table.insert(lines, "----------") 539 | for line in tostring(entry.desc):gmatch("[^\r\n]+") do 540 | table.insert(lines, line) 541 | end 542 | table.insert(lines, "Commit URL " .. (entry.commitUrl or "")) 543 | vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, lines) 544 | vim.api.nvim_buf_set_option(self.state.bufnr, "filetype", "markdown") 545 | end, 546 | }), 547 | attach_mappings = function(prompt_bufnr, _) 548 | actions.select_default:replace(function() 549 | actions.close(prompt_bufnr) 550 | local selection = action_state.get_selected_entry() 551 | if not selection then return end 552 | -- Compose content 553 | local lines = {} 554 | table.insert(lines, "# " .. (selection.title or "(no title)")) 555 | table.insert(lines, "") 556 | table.insert(lines, "**Author:** " .. (selection.author or "")) 557 | table.insert(lines, "**Date:** " .. (selection.date or "")) 558 | table.insert(lines, "") 559 | table.insert(lines, "## Description") 560 | table.insert(lines, "") 561 | for line in tostring(selection.desc):gmatch("[^\r\n]+") do 562 | table.insert(lines, line) 563 | end 564 | table.insert(lines, "") 565 | table.insert(lines, "---") 566 | table.insert(lines, "**Commit URL:** " .. (selection.commitUrl or "")) 567 | 568 | -- Open new vertical split 569 | vim.cmd("vsplit") 570 | local buf = vim.api.nvim_create_buf(false, true) 571 | vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) 572 | vim.api.nvim_set_current_buf(buf) 573 | vim.api.nvim_buf_set_option(buf, "filetype", "markdown") 574 | end) 575 | -- if selection then 576 | -- notify_inform("Selected commit: " .. (selection.title or "(unknown)")) 577 | -- end 578 | return true 579 | end, 580 | }) 581 | :find() 582 | end 583 | 584 | function A.query_descriptions_for_range(start_line, end_line) 585 | if not check_contextpilot_version() then return end 586 | local file_path = vim.api.nvim_buf_get_name(0) 587 | local folder_path = vim.loop.cwd() 588 | local title = string.format("Descriptions (%d-%d)", start_line, end_line) 589 | A.desc_data = {} 590 | 591 | -- Note: format according to your contextpilot usage 592 | local command = string.format( 593 | "%s %s -t desc %s -s %d -e %d", 594 | A.command, 595 | folder_path, 596 | file_path, 597 | start_line, 598 | end_line 599 | ) 600 | 601 | start_spinner_minimal("Processing descriptions...") 602 | vim.fn.jobstart(command, { 603 | stdout_buffered = true, 604 | on_stdout = append_desc_data, 605 | on_exit = function(_, exit_code) 606 | stop_spinner_minimal("✅ Descriptions retrieved.") 607 | if exit_code ~= 0 then 608 | notify_inform("Error: Command exited with code " .. exit_code, vim.log.levels.ERROR) 609 | elseif #A.desc_data > 0 then 610 | telescope_desc_picker(title) 611 | else 612 | notify_inform("No descriptions found.", vim.log.levels.WARN) 613 | end 614 | end, 615 | }) 616 | end 617 | 618 | function A.start_indexing_subdirectory() 619 | if not check_contextpilot_version() then return end 620 | local cwd = vim.loop.cwd() 621 | 622 | -- Parse JSON output from `contextpilot` 623 | local function parse_subdirs(output) 624 | local ok, result = pcall(vim.json.decode, output) 625 | if not ok or type(result) ~= "table" then 626 | notify_inform("Failed to parse subdirectory list from contextpilot.", vim.log.levels.ERROR) 627 | return {} 628 | end 629 | return result 630 | end 631 | 632 | local output = vim.fn.system(string.format("contextpilot %s -t listsubdirs", cwd)) 633 | if vim.v.shell_error ~= 0 then 634 | notify_inform("Failed to list subdirectories using contextpilot.", vim.log.levels.ERROR) 635 | return 636 | end 637 | 638 | local subdirs = parse_subdirs(output) 639 | if #subdirs == 0 then 640 | notify_inform("No subdirectories found from contextpilot.", vim.log.levels.WARN) 641 | return 642 | end 643 | 644 | -- Optional: preview logic 645 | local function render_tree(path, prefix) 646 | local plenary_scan = require("plenary.scandir") 647 | local lines = {} 648 | local items = plenary_scan.scan_dir(path, { 649 | depth = 1, 650 | hidden = false, 651 | add_dirs = true, 652 | }) 653 | table.sort(items) 654 | 655 | for _, item in ipairs(items) do 656 | local name = vim.fn.fnamemodify(item, ":t") 657 | if name:sub(1, 1) ~= "." then 658 | local is_dir = vim.fn.isdirectory(item) == 1 659 | if is_dir then 660 | table.insert(lines, prefix .. "📁 " .. name) 661 | local sub = render_tree(item, prefix .. " ├─ ") 662 | vim.list_extend(lines, sub) 663 | else 664 | table.insert(lines, prefix .. " ├─ " .. name) 665 | end 666 | end 667 | end 668 | 669 | return lines 670 | end 671 | 672 | telescope_pickers 673 | .new({}, { 674 | prompt_title = "Select Subdirectories to Index (Hit Tab to toggle selection)", 675 | finder = finders.new_table({ 676 | results = subdirs, 677 | entry_maker = function(entry) 678 | return { 679 | value = entry, 680 | ordinal = entry, 681 | display = function(entry, _) return entry.value or entry end, 682 | } 683 | end, 684 | }), 685 | sorter = sorters.get_fzy_sorter(), 686 | previewer = require("telescope.previewers").new_buffer_previewer({ 687 | define_preview = function(self, entry) 688 | local path = entry.value 689 | local abs_path = vim.fn.fnamemodify(path, ":p") 690 | local contents = render_tree(abs_path, "") 691 | if #contents == 0 then contents = { "(empty folder)" } end 692 | vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, contents) 693 | vim.api.nvim_buf_set_option(self.state.bufnr, "filetype", "markdown") 694 | end, 695 | }), 696 | attach_mappings = function(prompt_bufnr, map) 697 | map("i", "", actions.toggle_selection + actions.move_selection_next) 698 | map("n", "", actions.toggle_selection + actions.move_selection_next) 699 | 700 | actions.select_default:replace(function() 701 | local picker = action_state.get_current_picker(prompt_bufnr) 702 | local selections = picker:get_multi_selection() 703 | 704 | if #selections == 0 then 705 | local selected = action_state.get_selected_entry() 706 | if selected then table.insert(selections, selected) end 707 | end 708 | 709 | actions.close(prompt_bufnr) 710 | 711 | if #selections == 0 then 712 | notify_inform("No subdirectories selected.", vim.log.levels.WARN) 713 | return 714 | end 715 | 716 | local selected_dirs = vim.tbl_map(function(entry) return entry.value end, selections) 717 | 718 | local index_arg = table.concat(selected_dirs, ",") 719 | local command = string.format('%s %s -t index -i "%s"', A.command, cwd, index_arg) 720 | 721 | A.current_title = "Index Subdirectories: " .. index_arg 722 | A.autorun_data = {} 723 | 724 | start_spinner() 725 | vim.fn.jobstart(command, { 726 | stdout_buffered = false, 727 | stderr_buffered = true, 728 | on_stdout = append_data, 729 | on_exit = function(_, exit_code) 730 | stop_spinner() 731 | if exit_code ~= 0 then 732 | notify_inform("Error: Command exited with code " .. exit_code, vim.log.levels.ERROR) 733 | end 734 | end, 735 | }) 736 | end) 737 | 738 | return true 739 | end, 740 | }) 741 | :find() 742 | end 743 | 744 | -- Register Neovim user commands to expose plugin functionality 745 | 746 | -- Command to find relevant files for the entire current file 747 | vim.api.nvim_create_user_command( 748 | "ContextPilotRelevantFilesWholeFile", 749 | function() A.get_topn_contexts() end, 750 | {} 751 | ) 752 | -- Commented out command for current line context (can be uncommented if needed) 753 | -- vim.api.nvim_create_user_command( 754 | -- "ContextPilotContextsCurrentLine", 755 | -- function() A.get_topn_contexts_current_line() end, 756 | -- {} 757 | -- ) 758 | 759 | -- Command to start indexing the entire workspace 760 | vim.api.nvim_create_user_command("ContextPilotStartIndexing", function() A.start_indexing() end, {}) 761 | 762 | -- Command to find relevant files for a selected range (works with visual selection) 763 | vim.api.nvim_create_user_command("ContextPilotRelevantFilesRange", function(opts) 764 | -- Extract line numbers from the range selection 765 | local start_line = tonumber(opts.line1) 766 | local end_line = tonumber(opts.line2) 767 | -- Query contextpilot for the specified range 768 | A.query_context_for_range(start_line, end_line) 769 | end, { range = true }) -- Enable range support for visual selections 770 | 771 | -- Command to find relevant commit descriptions for a selected range 772 | vim.api.nvim_create_user_command("ContextPilotRelevantCommitsRange", function(opts) 773 | -- Extract line numbers from the range selection 774 | local start_line = tonumber(opts.line1) 775 | local end_line = tonumber(opts.line2) 776 | -- Query contextpilot for commit descriptions related to the range 777 | A.query_descriptions_for_range(start_line, end_line) 778 | end, { range = true }) -- Enable range support for visual selections 779 | 780 | -- Command to selectively index specific subdirectories 781 | vim.api.nvim_create_user_command( 782 | "ContextPilotIndexSubDirectory", 783 | function() A.start_indexing_subdirectory() end, 784 | {} 785 | ) 786 | 787 | -- Return the module table to make functions available to other Lua code 788 | return A 789 | --------------------------------------------------------------------------------