Repository: kiddos/gemini.nvim Branch: master Commit: 6e8474b0a099 Files: 16 Total size: 44.2 KB Directory structure: gitextract_p6arm2ii/ ├── .gitignore ├── .luacheckrs ├── LICENSE ├── Makefile ├── README.md ├── doc/ │ └── gemini.nvim.txt ├── lua/ │ ├── gemini/ │ │ ├── api.lua │ │ ├── chat.lua │ │ ├── completion.lua │ │ ├── config.lua │ │ ├── instruction.lua │ │ ├── task.lua │ │ └── util.lua │ └── gemini.lua └── tests/ ├── gemini/ │ └── api_spec.lua └── init.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ __pycache__ *.db lua/gemini/*.so .cache build doc/tags ================================================ FILE: .luacheckrs ================================================ read_globals = { "vim" } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Joseph Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE 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 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ TESTS_INIT=tests/init.lua TESTS_DIR=tests/ all: cmake -B build make -C build -j clean: rm -rf build rm -rf lua/gemini/*.so test: @nvim \ --headless \ --noplugin \ -u ${TESTS_INIT} \ -c "PlenaryBustedDirectory ${TESTS_DIR} { minimal_init = '${TESTS_INIT}' }" .PHONY: all clean test ================================================ FILE: README.md ================================================ # gemini.nvim This plugin try to interface Google's Gemini API into neovim. ## Features - Code Complete - Code Explain - Unit Test Generation - Code Review - Hints - Chat ### Code Complete https://github.com/user-attachments/assets/11ae6719-4f3f-41db-8ded-56db20e6e9f4 https://github.com/user-attachments/assets/34c38078-a028-47d2-acb1-49e03d0b4330 ### Do some changes https://github.com/user-attachments/assets/c0a001a2-a5fe-469d-ae01-3468d05b041c ### Code Explain https://github.com/user-attachments/assets/6b2492ee-7c70-4bbc-937b-27bfa50f8944 ### Unit Test generation https://github.com/user-attachments/assets/0620a8a4-5ea6-431d-ba17-41c7d553f742 ### Code Review https://github.com/user-attachments/assets/9100ab70-f107-40de-96e2-fb4ea749c014 ### Hints https://github.com/user-attachments/assets/a36804e9-073f-4e3e-9178-56b139fd0c62 ### Chat https://github.com/user-attachments/assets/d3918d2a-4cf7-4639-bc21-689d4225ba6d ## Installation - install `curl` ``` sudo apt install curl ``` ```shell export GEMINI_API_KEY="" ``` * [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua { 'kiddos/gemini.nvim', opts = {} } ``` * [packer.nvim](https://github.com/wbthomason/packer.nvim) ```lua use { 'kiddos/gemini.nvim', opts = {} } ``` ## Settings default setting ```lua { model_config = { model_id = 'gemini-2.5-flash', temperature = 0.10, top_k = 128, response_mime_type = 'text/plain', }, chat_config = { enabled = true, }, hints = { enabled = true, hints_delay = 2000, insert_result_key = '', get_prompt = function(node, bufnr) local code_block = vim.treesitter.get_node_text(node, bufnr) local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = [[ In struction: Use 1 or 2 sentences to describe what the following {filetype} function does: ```{filetype} {code_block} ``]] .. '`' prompt = prompt:gsub('{filetype}', filetype) prompt = prompt:gsub('{code_block}', code_block) return prompt end }, completion = { enabled = true, blacklist_filetypes = { 'help', 'qf', 'json', 'yaml', 'toml', 'xml' }, blacklist_filenames = { '.env' }, completion_delay = 800, insert_result_key = '', move_cursor_end = true, can_complete = function() return vim.fn.pumvisible() ~= 1 end, get_system_text = function() return "You are a coding AI assistant that autocomplete user's code." .. "\n* Your task is to provide code suggestion at the cursor location marked by ." .. '\n* Your response does not need to contain explaination.' end, get_prompt = function(bufnr, pos) local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Below is the content of a %s file `%s`:\n' .. '```%s\n%s\n```\n\n' .. 'Suggest the most likely code at .\n' .. 'Wrap your response in ``` ```\n' .. 'eg.\n```\n```\n\n' local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local line = pos[1] local col = pos[2] local target_line = lines[line] if target_line then lines[line] = target_line:sub(1, col) .. '' .. target_line:sub(col + 1) else return nil end local code = vim.fn.join(lines, '\n') local abs_path = vim.api.nvim_buf_get_name(bufnr) local filename = vim.fn.fnamemodify(abs_path, ':.') prompt = string.format(prompt, filetype, filename, filetype, code) return prompt end }, instruction = { enabled = true, menu_key = 'g', prompts = { { name = 'Unit Test', command_name = 'GeminiUnitTest', menu = 'Unit Test 🚀', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Write unit test for the above snippet of code\n' return string.format(prompt, filetype, code) end, }, { name = 'Code Review', command_name = 'GeminiCodeReview', menu = 'Code Review 📜', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Do a thorough code review for the following code.\n' .. 'Provide detail explaination and sincere comments.\n' return string.format(prompt, filetype, code) end, }, { name = 'Code Explain', command_name = 'GeminiCodeExplain', menu = 'Code Explain', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Explain the following code.\n' .. 'Provide detail explaination and sincere comments.\n' return string.format(prompt, filetype, code) end, }, } }, task = { enabled = true, get_system_text = function() return 'You are an AI assistant that helps user write code.' .. '\n* You should output the new content for the Current Opened File' end, get_prompt = function(bufnr, user_prompt) local buffers = vim.api.nvim_list_bufs() local file_contents = {} for _, b in ipairs(buffers) do if vim.api.nvim_buf_is_loaded(b) then -- Only get content from loaded buffers local lines = vim.api.nvim_buf_get_lines(b, 0, -1, false) local abs_path = vim.api.nvim_buf_get_name(b) local filename = vim.fn.fnamemodify(abs_path, ':.') local filetype = vim.api.nvim_get_option_value('filetype', { buf = b }) local file_content = table.concat(lines, "\n") file_content = string.format("`%s`:\n\n```%s\n%s\n```\n\n", filename, filetype, file_content) table.insert(file_contents, file_content) end end local current_filepath = vim.api.nvim_buf_get_name(bufnr) current_filepath = vim.fn.fnamemodify(current_filepath, ":.") local context = table.concat(file_contents, "\n\n") return string.format('%s\n\nCurrent Opened File: %s\n\nTask: %s', context, current_filepath, user_prompt) end }, } ``` ================================================ FILE: doc/gemini.nvim.txt ================================================ *gemini.nvim* Google's Gemini neovim binding INTRODUCTION *gemini.nvim-intro* This plugin try to interface Google's Gemini API into neovim. INSTALLATION *gemini.nvim-install* install curl sudo apt install curl setup API key export GEMINI_API_KEY="" lazy.nvim: { 'kiddos/gemini.nvim', opts = {} } CONFIGURATION *gemini.nvim-config* { model_config = { completion_delay = 1000, model_id = api.MODELS.GEMINI_2_0_FLASH, temperature = 0.2, top_k = 20, max_output_tokens = 8196, response_mime_type = 'text/plain', }, chat_config = { enabled = true, }, hints = { enabled = true, hints_delay = 2000, insert_result_key = '', get_prompt = function(node, bufnr) local code_block = vim.treesitter.get_node_text(node, bufnr) local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = " Instruction: Use 1 or 2 sentences to describe what the following {filetype} function does: ```{filetype} {code_block} ```", prompt = prompt:gsub('{filetype}', filetype) prompt = prompt:gsub('{code_block}', code_block) return prompt end } completion = { enabled = true, blacklist_filetypes = { 'help', 'qf', 'json', 'yaml', 'toml' }, blacklist_filenames = { '.env' }, completion_delay = 600, move_cursor_end = false, insert_result_key = '', can_complete = function() return vim.fn.pumvisible() ~= 1 end, get_system_text = function() return "You are a coding AI assistant that autocomplete user's code." .. "\n* Your task is to provide code suggestion at the cursor location marked by ." .. '\n* Do not wrap your code response in ```' end, get_prompt = function(bufnr, pos) local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Below is the content of a %s file `%s`:\n' .. '```%s\n%s\n```\n\n' .. 'Suggest the most likely code at .\n' local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local line = pos[1] local col = pos[2] local target_line = lines[line] if target_line then lines[line] = target_line:sub(1, col) .. '' .. target_line:sub(col + 1) else return nil end local code = vim.fn.join(lines, '\n') local filename = vim.api.nvim_buf_get_name(bufnr) prompt = string.format(prompt, filetype, filename, filetype, code) return prompt end }, instruction = { enabled = true, menu_key = '', prompts = { { name = 'Unit Test', command_name = 'GeminiUnitTest', menu = 'Unit Test 🚀', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Write unit test for the above snippet of code\n' return string.format(prompt, filetype, code) end, }, { name = 'Code Review', command_name = 'GeminiCodeReview', menu = 'Code Review 📜', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Do a thorough code review for the following code.\n' .. 'Provide detail explaination and sincere comments.\n' return string.format(prompt, filetype, code) end, }, { name = 'Code Explain', command_name = 'GeminiCodeExplain', menu = 'Code Explain', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Explain the following code.\n' .. 'Provide detail explaination and sincere comments.\n' return string.format(prompt, filetype, code) end, }, }, }, task = { enabled = true, get_system_text = function() return 'You are an AI assistant that helps user write code.\n' .. 'Your output should be a code diff for git.' end, get_prompt = function(bufnr, user_prompt) local buffers = vim.api.nvim_list_bufs() local file_contents = {} for _, b in ipairs(buffers) do if vim.api.nvim_buf_is_loaded(b) then -- Only get content from loaded buffers local lines = vim.api.nvim_buf_get_lines(b, 0, -1, false) local filename = vim.api.nvim_buf_get_name(b) filename = vim.fn.fnamemodify(filename, ":.") local filetype = vim.api.nvim_get_option_value('filetype', { buf = b }) local file_content = table.concat(lines, "\n") file_content = string.format("`%s`:\n\n```%s\n%s\n```\n\n", filename, filetype, file_content) table.insert(file_contents, file_content) end end local current_filepath = vim.api.nvim_buf_get_name(bufnr) current_filepath = vim.fn.fnamemodify(current_filepath, ":.") local context = table.concat(file_contents, "\n\n") return string.format('%s\n\nCurrent Opened File: %s\n\nTask: %s', context, current_filepath, user_prompt) end }, } COMMANDS *gemini.nvim-commands* :GeminiTask ask Gemini to complete some task. this would use current opened buffer as context. this would also open up a diff pane in the current window :GeminiApply this would apply the diff result from :GeminiTask :GeminiChat ask Gemini to do something. this doesn't use any context :GeminiCodeExplain select some code and ask Gemini what does it do :GeminiCodeReview do some code review :GeminiUnitTest let Gemini write your unit test :GeminiFunctionHint give some function documentation MAPPINGS *gemini.nvim-mappings* g open a popup menu to select some task to do confirm gemini's autocomplete ================================================ FILE: lua/gemini/api.lua ================================================ local uv = vim.loop or vim.uv local M = {} local API = "https://generativelanguage.googleapis.com/v1beta/models/"; M.MODELS = { GEMINI_3_FLASH_PREVIEW = 'gemini-3-flash-preview', GEMINI_FLASH_LATEST = 'gemini-flash-latest', GEMINI_3_1_PRO_PREVIEW = 'gemini-3.1-pro-preview', GEMINI_3_1_FLASH_LITE_PREVIEW = 'gemini-3.1-flash-lite-preview', GEMINI_FLASH_LITE_LATEST = 'gemini-flash-lite-latest', GEMINI_2_5_PRO = 'gemini-2.5-pro', GEMINI_2_5_FLASH = 'gemini-2.5-flash', GEMINI_2_5_FLASH_LITE = 'gemini-2.5-flash-lite', GEMINI_2_0_FLASH = 'gemini-2.0-flash', GEMINI_2_0_FLASH_LITE = 'gemini-2.0-flash-lite', } M.gemini_generate_content = function(user_text, system_text, model_name, generation_config, callback) local api_key = os.getenv("GEMINI_API_KEY") if not api_key then return '' end local api = API .. model_name .. ':generateContent?key=' .. api_key local contents = { { parts = { { text = user_text } } } } local data = { contents = contents, generationConfig = generation_config, } if system_text then data.systemInstruction = { parts = { { text = system_text, } } } end local json_text = vim.json.encode(data) local cmd = { 'curl', '--no-progress-meter', '-X', 'POST', api, '-H', 'Content-Type: application/json', '--data-binary', '@-' } local opts = { stdin = json_text } if callback then return vim.system(cmd, opts, callback) else return vim.system(cmd, opts) end end M.gemini_generate_content_stream = function(user_text, model_name, generation_config, callback) local api_key = os.getenv("GEMINI_API_KEY") if not api_key then return end if not callback then return end local api = API .. model_name .. ':streamGenerateContent?alt=sse&key=' .. api_key local data = { contents = { { parts = { { text = user_text } } } }, generationConfig = generation_config, } local json_text = vim.json.encode(data) local stdin = uv.new_pipe() local stdout = uv.new_pipe() local stderr = uv.new_pipe() local options = { stdio = { stdin, stdout, stderr }, args = { api, '-X', 'POST', '-s', '-H', 'Content-Type: application/json', '-d', json_text } } uv.spawn('curl', options, function(code, _) print("gemini chat finished exit code", code) end) local streamed_data = '' uv.read_start(stdout, function(err, data) if not err and data then streamed_data = streamed_data .. data local start_index = string.find(streamed_data, 'data:') local end_index = string.find(streamed_data, '\r') local json_text = '' while start_index and end_index do if end_index >= start_index then json_text = string.sub(streamed_data, start_index + 5, end_index - 1) callback(json_text) end streamed_data = string.sub(streamed_data, end_index + 1) start_index = string.find(streamed_data, 'data:') end_index = string.find(streamed_data, '\r') end end end) end return M ================================================ FILE: lua/gemini/chat.lua ================================================ local config = require('gemini.config') local util = require('gemini.util') local api = require('gemini.api') local M = {} M.setup = function() local model = config.get_config({ 'chat', 'model' }) if not model or not model.model_id then return end vim.api.nvim_create_user_command('GeminiChat', M.start_chat, { force = true, desc = 'Google Gemini', nargs = 1, }) end local context = { chat_winnr = nil, chat_number = 0, } local function get_bufnr(user_text) local conf = config.get_config({ 'chat' }) if not conf then vim.api.nvim_command('tabnew') local bufnr = vim.api.nvim_get_current_buf() vim.api.nvim_set_option_value('ft', 'markdown', { buf = bufnr }) return bufnr end local bufnr = nil if not context.chat_winnr or not vim.api.nvim_win_is_valid(context.chat_winnr) or conf.window.position == 'new_tab' then if conf.window.position == 'tab' or conf.window.position == 'new_tab' then vim.api.nvim_command('tabnew') elseif conf.window.position == 'left' then vim.api.nvim_command('vertical topleft split new') vim.api.nvim_win_set_width(0, conf.window.width or 80) elseif conf.window.position == 'right' then vim.api.nvim_command('rightbelow vnew') vim.api.nvim_win_set_width(0, conf.window.width or 80) end context.chat_winnr = vim.api.nvim_tabpage_get_win(0) bufnr = vim.api.nvim_win_get_buf(0) end vim.api.nvim_set_current_win(context.chat_winnr) bufnr = bufnr or vim.api.nvim_win_get_buf(0) vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr }) vim.api.nvim_set_option_value('ft', 'markdown', { buf = bufnr }) vim.api.nvim_buf_set_name(bufnr, 'Chat' .. context.chat_number .. ': ' .. user_text) return vim.api.nvim_win_get_buf(0) end M.start_chat = function(cxt) local user_text = cxt.args context.chat_number = context.chat_number + 1 local bufnr = get_bufnr(user_text) local lines = { 'Generating response...' } vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) local generation_config = config.get_gemini_generation_config('chat') local text = '' local model_id = config.get_config({ 'chat', 'model', 'model_id' }) api.gemini_generate_content_stream(user_text, model_id, generation_config, function(json_text) local model_response = vim.json.decode(json_text) model_response = util.table_get(model_response, { 'candidates', 1, 'content', 'parts', 1, 'text' }) if not model_response then return end text = text .. model_response vim.schedule(function() lines = vim.split(text, '\n') vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) end) end) end return M ================================================ FILE: lua/gemini/completion.lua ================================================ local config = require('gemini.config') local util = require('gemini.util') local api = require('gemini.api') local M = {} local context = { namespace_id = vim.api.nvim_create_namespace('gemini_completion'), completion = nil, } M.setup = function() local model = config.get_config({ 'completion', 'model' }) if not model or not model.model_id then return end vim.api.nvim_create_autocmd('CursorMovedI', { callback = function() M.gemini_complete() end, }) local trigger_completion_key = config.get_config({ 'completion', 'trigger_key' }) if trigger_completion_key then vim.api.nvim_set_keymap('i', trigger_completion_key, '', { callback = function() M.gemini_complete() end, }) end local insert_key = config.get_config({ 'completion', 'insert_result_key' }) or '' vim.api.nvim_set_keymap('i', insert_key, '', { callback = function() M.insert_completion_result() end, }) end local get_prompt_text = function(bufnr, pos) local get_prompt = config.get_config({ 'completion', 'get_prompt' }) if not get_prompt then vim.notify('prompt function is not found', vim.log.levels.WARN) return nil end return get_prompt(bufnr, pos) end local function handle_result(win, pos, json_text) local model_response = vim.json.decode(json_text) local text = util.table_get(model_response, { 'candidates', 1, 'content', 'parts', 1, 'text' }) if model_response ~= nil and #model_response > 0 then vim.schedule(function() if model_response then local code_blocks = util.strip_code(text) local response = vim.fn.join(code_blocks, '\n\n') M.show_completion_result(response, win, pos) end end) end local error_msg = util.table_get(model_response, { 'error', 'message' }) if error_msg then vim.schedule(function() vim.api.nvim_echo({{ error_msg, 'WarningMsg' }}, false, {}) end) end end M._gemini_complete = function() local bufnr = vim.api.nvim_get_current_buf() local win = vim.api.nvim_get_current_win() local pos = vim.api.nvim_win_get_cursor(win) local user_text = get_prompt_text(bufnr, pos) if not user_text then return end local system_text = nil local get_system_text = config.get_config({ 'completion', 'get_system_text' }) if get_system_text then system_text = get_system_text() end local generation_config = config.get_gemini_generation_config('completion') local model_id = config.get_config({ 'completion', 'model', 'model_id' }) api.gemini_generate_content(user_text, system_text, model_id, generation_config, function(result) local json_text = result.stdout if json_text and #json_text > 0 then handle_result(win, pos, json_text) end end) end M.gemini_complete = util.debounce(function() local blacklist_filetypes = config.get_config({ 'completion', 'blacklist_filetypes' }) or {} local blacklist_filenames = config.get_config({ 'completion', 'blacklist_filenames' }) or {} local buf = vim.api.nvim_get_current_buf() local filetype = vim.api.nvim_get_option_value('filetype', { buf = buf }) local filename = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(0), ":t") if util.is_blacklisted(blacklist_filetypes, filetype) or util.is_blacklisted(blacklist_filenames, filename) then return end if vim.fn.mode() ~= 'i' then return end local can_complete = config.get_config({'completion', 'can_complete'}) if not can_complete or not can_complete() then return end print('-- gemini complete --') M._gemini_complete() end, config.get_config({ 'completion', 'completion_delay' }) or 1000) M.show_completion_result = function(result, win_id, pos) local win = vim.api.nvim_get_current_win() if win ~= win_id then return end local current_pos = vim.api.nvim_win_get_cursor(win) if current_pos[1] ~= pos[1] or current_pos[2] ~= pos[2] then return end if vim.fn.mode() ~= 'i' then return end local can_complete = config.get_config({'completion', 'can_complete'}) if not can_complete or not can_complete() then return end local bufnr = vim.api.nvim_get_current_buf() local options = { id = 1, virt_text = {}, virt_lines = {}, hl_mode = 'combine', virt_text_pos = 'inline', } local content = result:match("^%s*(.-)%s*$") for i, l in pairs(vim.split(content, '\n')) do if i == 1 then options.virt_text[1] = { l, 'Comment' } else options.virt_lines[i - 1] = { { l, 'Comment' } } end end local row = pos[1] local col = pos[2] local id = vim.api.nvim_buf_set_extmark(bufnr, context.namespace_id, row - 1, col, options) context.completion = { content = content, row = row, col = col, bufnr = bufnr, } vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'InsertLeavePre' }, { buffer = bufnr, callback = function() context.completion = nil vim.api.nvim_buf_del_extmark(bufnr, context.namespace_id, id) vim.api.nvim_command('redraw') end, once = true, }) end M.insert_completion_result = function() if not context.completion then return end local bufnr = vim.api.nvim_get_current_buf() if not context.completion.bufnr == bufnr then return end local row = context.completion.row - 1 local col = context.completion.col local first_line = vim.api.nvim_buf_get_lines(0, row, row + 1, false)[1] local lines = vim.split(context.completion.content, '\n') lines[1] = string.sub(first_line, 1, col) .. lines[1] .. string.sub(first_line, col + 1) vim.api.nvim_buf_set_lines(bufnr, row, row + 1, false, lines) if config.get_config({ 'completion', 'move_cursor_end' }) == true then local new_row = row + #lines local new_col = #vim.api.nvim_buf_get_lines(0, new_row - 1, new_row, false)[1] vim.api.nvim_win_set_cursor(0, { new_row, new_col }) end context.completion = nil end return M ================================================ FILE: lua/gemini/config.lua ================================================ local api = require('gemini.api') local util = require('gemini.util') local M = {} local default_temperature = 0.06 local default_top_k = 64 local default_chat_config = { model = { model_id = api.MODELS.GEMINI_2_5_FLASH, temperature = default_temperature, top_k = default_top_k, }, window = { position = "new_tab", -- left, right, new_tab, tab width = 80, -- number of columns of the left/right window } } local default_instruction_config = { model = { model_id = api.MODELS.GEMINI_2_5_FLASH, temperature = default_temperature, top_k = default_top_k, }, menu_key = 'g', prompts = { { name = 'Unit Test', command_name = 'GeminiUnitTest', menu = 'Unit Test 🚀', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Write unit test for the above snippet of code\n' return string.format(prompt, filetype, code) end, }, { name = 'Code Review', command_name = 'GeminiCodeReview', menu = 'Code Review 📜', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Do a thorough code review for the following code.\n' .. 'Provide detail explaination and sincere comments.\n' return string.format(prompt, filetype, code) end, }, { name = 'Code Explain', command_name = 'GeminiCodeExplain', menu = 'Code Explain', get_prompt = function(lines, bufnr) local code = vim.fn.join(lines, '\n') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local prompt = 'Context:\n\n```%s\n%s\n```\n\n' .. 'Objective: Explain the following code.\n' .. 'Provide detail explaination and sincere comments.\n' return string.format(prompt, filetype, code) end, }, } } local default_completion_config = { model = { model_id = api.MODELS.GEMINI_2_5_FLASH, temperature = default_temperature, top_k = default_top_k, }, blacklist_filetypes = { 'help', 'qf', 'json', 'yaml', 'toml', 'xml', 'ini' }, blacklist_filenames = { '.env' }, completion_delay = 800, insert_result_key = '', move_cursor_end = true, can_complete = function() return vim.fn.pumvisible() ~= 1 end, get_system_text = function() return "I need you to act as a pure code-completion tool." end, get_prompt = function(bufnr, pos) local abs_path = vim.api.nvim_buf_get_name(bufnr) local filename = vim.fn.fnamemodify(abs_path, ':.') local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local radius = 10 local row = pos[1] local col = pos[2] local start_line = math.max(1, row - radius) local line_count = vim.api.nvim_buf_line_count(bufnr) local end_line = math.min(line_count, row + radius) local lines = vim.api.nvim_buf_get_lines(bufnr, start_line, end_line, false) local prompt = 'I have a %s file %s. Below is a snippet around the missing code block.\n' prompt = string.format(prompt, filetype, filename) prompt = prompt .. '[CODE BEFORE GAP]\n' if start_line > 1 then prompt = prompt .. "// ... (earlier code omitted)\n" end for i, line in ipairs(lines) do local current_buffer_row = start_line + i - 1 if current_buffer_row == row then local line_prefix = line:sub(1, col) local line_suffix = line:sub(col + 1) prompt = prompt .. line_prefix .. '\n\n[CODE AFTER GAP]\n' .. line_suffix .. '\n' else prompt = prompt .. line .. '\n' end end if end_line < line_count then prompt = prompt .. "// ... (later code omitted)\n" end prompt = prompt .. '\n[INSTRUCTION]\n' prompt = prompt .. 'Generate EXACTLY the code that fills the gap between the two blocks. ' prompt = prompt .. 'Return ONLY the code. No explanation. Stop immediately when the logic is complete.\n' return prompt end } local default_task_config = { model = { model_id = api.MODELS.GEMINI_2_5_FLASH, temperature = default_temperature, top_k = default_top_k, }, get_system_text = function() return 'You are an AI assistant that helps user write code.' .. '\n* You should output the new content for the Current Opened File' end, get_prompt = function(bufnr, user_prompt) local buffers = vim.api.nvim_list_bufs() local file_contents = {} for _, b in ipairs(buffers) do if vim.api.nvim_buf_is_loaded(b) then -- Only get content from loaded buffers local lines = vim.api.nvim_buf_get_lines(b, 0, -1, false) local abs_path = vim.api.nvim_buf_get_name(b) local filename = vim.fn.fnamemodify(abs_path, ':.') local filetype = vim.api.nvim_get_option_value('filetype', { buf = b }) local file_content = table.concat(lines, "\n") file_content = string.format("`%s`:\n\n```%s\n%s\n```\n\n", filename, filetype, file_content) table.insert(file_contents, file_content) end end local current_filepath = vim.api.nvim_buf_get_name(bufnr) current_filepath = vim.fn.fnamemodify(current_filepath, ":.") local context = table.concat(file_contents, "\n\n") return string.format('%s\n\nCurrent Opened File: %s\n\nTask: %s', context, current_filepath, user_prompt) end } M.set_config = function(opts) opts = opts or {} M.config = { chat = vim.tbl_deep_extend('force', {}, default_chat_config, opts.chat_config or {}), completion = vim.tbl_deep_extend('force', {}, default_completion_config, opts.completion or {}), instruction = vim.tbl_deep_extend('force', {}, default_instruction_config, opts.instruction or {}), task = vim.tbl_deep_extend('force', {}, default_task_config, opts.task or {}) } end M.get_config = function(keys) return util.table_get(M.config, keys) end M.get_gemini_generation_config = function(space) return { temperature = M.get_config({ space, 'model', 'temperature' }) or default_temperature, topK = M.get_config({ space, 'model', 'top_k' }) or default_top_k, response_mime_type = 'text/plain', thinkingConfig = { thinkingBudget = 0 } } end return M ================================================ FILE: lua/gemini/instruction.lua ================================================ local config = require('gemini.config') local util = require('gemini.util') local api = require('gemini.api') local M = {} M.setup = function() local model = config.get_config({ 'instruction', 'model' }) if not model or not model.model_id then return end local register_menu = function(prompt_item) local get_prompt = prompt_item.get_prompt if not get_prompt then return end local command_name = prompt_item.command_name if not command_name then return end local gemini_generate = function(context) local bufnr = vim.api.nvim_get_current_buf() local lines if not context.line1 or not context.line2 or context.line1 == context.line2 then lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) else lines = vim.api.nvim_buf_get_lines(bufnr, context.line1 - 1, context.line2 - 1, false) end local user_text = get_prompt(lines, bufnr) local generation_config = config.get_gemini_generation_config('instruction') vim.api.nvim_command('tabnew') local new_buf = vim.api.nvim_get_current_buf() vim.api.nvim_set_option_value('filetype', 'markdown', { buf = new_buf }) local model_id = config.get_config({ 'instruction', 'model', 'model_id' }) local text = '' api.gemini_generate_content_stream(user_text, model_id, generation_config, function(json_text) local model_response = vim.json.decode(json_text) model_response = util.table_get(model_response, { 'candidates', 1, 'content', 'parts', 1, 'text' }) text = text .. model_response vim.schedule(function() lines = vim.split(text, '\n') vim.api.nvim_buf_set_lines(new_buf, 0, -1, false, lines) end) end) end vim.api.nvim_create_user_command(command_name, gemini_generate, { range = true, }) local menu = prompt_item.menu if menu then menu = menu:gsub(' ', '\\ ') vim.api.nvim_command('nnoremenu Gemini.' .. menu .. ' :' .. command_name .. '') end end for _, item in pairs(config.get_config({ 'instruction', 'prompts' }) or {}) do register_menu(item) end local register_keymap = function(mode, keymap) vim.api.nvim_set_keymap(mode, keymap, '', { expr = true, noremap = true, silent = true, callback = function() if vim.fn.pumvisible() == 0 then vim.api.nvim_command('popup Gemini') end end }) end local modes = { 'n' } for _, mode in pairs(modes) do register_keymap(mode, config.get_config({ 'instruction', 'menu_key' }) or 'g') end end return M ================================================ FILE: lua/gemini/task.lua ================================================ local config = require('gemini.config') local api = require('gemini.api') local util = require('gemini.util') local M = {} local context = { bufnr = nil, model_response = nil, tmpfile = nil, } M.setup = function() local model = config.get_config({ 'task', 'model' }) if not model or not model.model_id then return end vim.api.nvim_create_user_command('GeminiTask', M.run_task, { force = true, desc = 'Google Gemini', nargs = 1, }) vim.api.nvim_create_user_command('GeminiApply', M.apply_patch, { force = true, desc = 'Apply patch', }) end local get_prompt_text = function(bufnr, user_prompt) local get_prompt = config.get_config({ 'task', 'get_prompt' }) if not get_prompt then vim.notify('prompt function is not found', vim.log.levels.WARN) return nil end return get_prompt(bufnr, user_prompt) end local function open_file_in_split(filepath, ft) local bufnr = vim.fn.bufnr(filepath, true) if bufnr == 0 then print("Error: Could not find or create buffer for file: " .. filepath) return end vim.api.nvim_set_option_value('filetype', ft, {buf = bufnr}) local win_id = vim.api.nvim_open_win(bufnr, false, { split = 'right', win = 0, }) vim.api.nvim_set_current_win(win_id) vim.api.nvim_set_option_value('diff', true, { win = win_id }) vim.api.nvim_set_option_value('scrollbind', true, { win = win_id }) vim.api.nvim_set_option_value('cursorbind', true, { win = win_id }) end local function diff_with_current_file(bufnr, new_content) local tmpfile = vim.fn.tempname() -- Write to the temp file local f = io.open(tmpfile, "w") if f then f:write(new_content) f:close() end local ft = vim.api.nvim_get_option_value('filetype', {buf = bufnr}) open_file_in_split(vim.fn.fnameescape(tmpfile), ft) return tmpfile end M.run_task = function(ctx) local bufnr = vim.api.nvim_get_current_buf() local user_prompt = ctx.args local prompt = get_prompt_text(bufnr, user_prompt) local system_text = nil local get_system_text = config.get_config({ 'task', 'get_system_text' }) if get_system_text then system_text = get_system_text() end print('-- running Gemini Task...') local generation_config = config.get_gemini_generation_config('task') local model_id = config.get_config({ 'task', 'model', 'model_id' }) api.gemini_generate_content(prompt, system_text, model_id, generation_config, function(result) local json_text = result.stdout if json_text and #json_text > 0 then local model_response = vim.json.decode(json_text) model_response = util.table_get(model_response, { 'candidates', 1, 'content', 'parts', 1, 'text' }) if model_response ~= nil and #model_response > 0 then model_response = util.strip_code(model_response) vim.schedule(function() model_response = vim.fn.join(model_response, '\n') if #model_response then context.bufnr = bufnr context.model_response = model_response context.tmpfile = diff_with_current_file(bufnr, model_response) end end) end end end) end local function close_split_by_filename(tmpfile) -- Get the buffer number for the temp file local bufnr = vim.fn.bufnr(tmpfile) if bufnr == -1 then print("No buffer found for file: " .. tmpfile) return end -- Find the window displaying this buffer for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == bufnr then vim.api.nvim_win_close(win, true) -- force close the window vim.api.nvim_buf_delete(bufnr, { force = true, unload = true }) return end end print("No window found showing the buffer for file: " .. tmpfile) end M.apply_patch = function() if not context.bufnr or not context.tmpfile then vim.notify('No Gemini task to apply.', vim.log.levels.WARN) return end print('-- apply changes from Gemini') local tmp_bufnr = vim.fn.bufnr(vim.fn.fnamemodify(context.tmpfile, ':p')) if tmp_bufnr == -1 then vim.notify('Could not find the temporary buffer for edited changes.', vim.log.levels.ERROR) return end local edited_lines = vim.api.nvim_buf_get_lines(tmp_bufnr, 0, -1, false) vim.api.nvim_buf_set_lines(context.bufnr, 0, -1, false, edited_lines) if context.tmpfile then close_split_by_filename(context.tmpfile) end context.bufnr = nil context.model_response = nil context.tmpfile = nil end return M ================================================ FILE: lua/gemini/util.lua ================================================ local M = {} M.borderchars = { '─', '│', '─', '│', '╭', '╮', '╯', '╰' } M.open_window = function(content, options) local popup = require('plenary.popup') options.borderchars = M.borderchars local win_id, result = popup.create(content, options) local bufnr = vim.api.nvim_win_get_buf(win_id) local border = result.border vim.api.nvim_set_option_value('ft', 'markdown', { buf = bufnr }) vim.api.nvim_set_option_value('wrap', true, { win = win_id }) local close_popup = function() vim.api.nvim_win_close(win_id, true) end local keys = { '', 'q' } for _, key in pairs(keys) do vim.api.nvim_buf_set_keymap(bufnr, 'n', key, '', { silent = true, callback = close_popup, }) end return win_id, bufnr, border end M.treesitter_has_lang = function(bufnr) local filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }) local lang = vim.treesitter.language.get_lang(filetype) return lang ~= nil end M.find_node_by_type = function(node_type) local node = vim.treesitter.get_node() while node do local type = node:type() if string.find(type, node_type) then return node end local parent = node:parent() if parent == node then break end node = parent end return nil end M.debounce = function(callback, timeout) local timer = nil local f = function(...) local t = { ... } local handler = function() callback(unpack(t)) end if timer ~= nil then timer:stop() end timer = vim.defer_fn(handler, timeout) end return f end M.table_get = function(t, id) if type(id) ~= 'table' then return M.table_get(t, { id }) end local success, res = true, t for _, i in ipairs(id) do success, res = pcall(function() return res[i] end) if not success or res == nil then return end end return res end M.is_blacklisted = function(blacklist, filetype) for _, ft in ipairs(blacklist) do if string.find(filetype, ft, 1, true) ~= nil then return true end end return false end M.strip_code = function(text) local code_blocks = {} if not text then return code_blocks end local pattern = "```(%w+)%s*(.-)%s*```" for _, code_block in text:gmatch(pattern) do table.insert(code_blocks, code_block) end if #code_blocks == 0 then return { text } end return code_blocks end return M ================================================ FILE: lua/gemini.lua ================================================ local config = require('gemini.config') local M = {} local function is_nvim_version_ge(major, minor, patch) local v = vim.version() if v.major > major then return true elseif v.major == major then if v.minor > minor then return true elseif v.minor == minor and v.patch >= patch then return true end end return false end M.setup = function(opts) if not vim.fn.executable('curl') then vim.notify('curl is not found', vim.log.levels.WARN) return end if not is_nvim_version_ge(0, 10, 0) then vim.notify('neovim version too old', vim.log.levels.WARN) return end config.set_config(opts) require('gemini.chat').setup() require('gemini.instruction').setup() require('gemini.completion').setup() require('gemini.task').setup() end return M ================================================ FILE: tests/gemini/api_spec.lua ================================================ local api = require('gemini.api') local util = require('gemini.util') describe('api', function() it('should send message', function() local generation_config = { temperature = 0.9, top_k = 1.0, max_output_tokens = 2048, response_mime_type = 'text/plain', } local future = api.gemini_generate_content('hello there', nil, api.MODELS.GEMINI_2_0_FLASH, generation_config, nil) local result = future:wait() local stdout = result.stdout print(stdout) assert(#stdout > 0) local result = vim.json.decode(stdout) local model_response = util.table_get(result, { 'candidates', 1, 'content', 'parts', 1, 'text' }) assert(#model_response > 0) end) it('should send long message', function() local generation_config = { temperature = 0.9, top_k = 1.0, max_output_tokens = 2048, response_mime_type = 'text/plain', } local long_message = string.rep('this is a very very long message ', 3000) local future = api.gemini_generate_content(long_message, nil, api.MODELS.GEMINI_2_0_FLASH, generation_config, nil) local result = future:wait() local stdout = result.stdout print(stdout) assert(#stdout > 0) local result = vim.json.decode(stdout) local model_response = util.table_get(result, { 'candidates', 1, 'content', 'parts', 1, 'text' }) assert(#model_response > 0) end) end) ================================================ FILE: tests/init.lua ================================================ local plenary_dir = os.getenv("PLENARY_DIR") or "/tmp/plenary.nvim" local is_not_a_directory = vim.fn.isdirectory(plenary_dir) == 0 if is_not_a_directory then vim.fn.system({"git", "clone", "https://github.com/nvim-lua/plenary.nvim", plenary_dir}) end vim.opt.rtp:append(".") vim.opt.rtp:append(plenary_dir) vim.cmd("runtime plugin/plenary.vim") require("plenary.busted")