From 2690f5521a6c8f3a4e5a34922ca9f7ee17d22041 Mon Sep 17 00:00:00 2001 From: Erikson Kaszubowski Date: Thu, 14 May 2026 12:02:05 -0300 Subject: [PATCH 1/4] fix: comply with MCP IDE companion spec for diff tools - openDiff: Return empty content on success instead of text message - closeDiff: Return file content (from scratch buffer) in TextContent - accept_diff: Use scratch buffer content to preserve user edits These changes ensure proper async notification flow and prevent the agent from receiving stale file state when re-proposing changes. --- cmd/gemini-server/main.go | 6 +----- lua/gemini/init.lua | 1 + lua/gemini/tools.lua | 28 ++++++++++++++++++++++------ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/cmd/gemini-server/main.go b/cmd/gemini-server/main.go index 75a1a46..bc86886 100644 --- a/cmd/gemini-server/main.go +++ b/cmd/gemini-server/main.go @@ -323,11 +323,7 @@ func registerTools(s *mcp.Server) { return nil, nil, fmt.Errorf("%s", errStr) } return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{ - Text: "Diff view opened in Neovim.", - }, - }, + Content: []mcp.Content{}, }, nil, nil }) diff --git a/lua/gemini/init.lua b/lua/gemini/init.lua index 5d69212..8fdfd6a 100644 --- a/lua/gemini/init.lua +++ b/lua/gemini/init.lua @@ -208,6 +208,7 @@ You are working alongside a human developer who may be **actively editing** the * **Usage:** Use `getReferences` to see how code is being used across the project. **INTERACTION HINTS:** +* **Creating New Files:** When creating a brand-new file, ALWAYS attempt to `read_file` the path first (even if it doesn't exist yet). This "warms up" the path in the internal tracker and prevents a known crash in the `write_file` tool. * If you see `[Output Redacted]`, the file is too large. Use `readSymbol` or `getFileOutline` instead. * You have access to the user's cursor via `ide/contextUpdate`. If the user says "here" or "this", refer to the active file/cursor in that context. ]]) diff --git a/lua/gemini/tools.lua b/lua/gemini/tools.lua index 1adbcf0..0f9c326 100644 --- a/lua/gemini/tools.lua +++ b/lua/gemini/tools.lua @@ -207,17 +207,20 @@ function M.accept_diff(file_path) return end - local new_lines = vim.split(session.new_content, "\n") - vim.api.nvim_buf_set_lines(session.bufnr, 0, -1, false, new_lines) + local lines = vim.api.nvim_buf_get_lines(session.scratch_buf, 0, -1, false) + local new_content = table.concat(lines, "\n") + if #lines > 0 then + new_content = new_content .. "\n" + end + + vim.api.nvim_buf_set_lines(session.bufnr, 0, -1, false, lines) - -- Mark as unmodified so external tools (gemini edit) - -- can write to disk without triggering a W12 conflict warning. vim.api.nvim_set_option_value("modified", false, { buf = session.bufnr }) if session.job_id then vim.rpcnotify(session.job_id, "diff_accepted", { filePath = file_path, - content = session.new_content, + content = new_content, }) end @@ -278,8 +281,21 @@ end function M.close_diff(file_path) file_path = vim.fn.fnamemodify(file_path, ":p") + local session = diff_sessions[file_path] + + if not session then + return { content = "" } + end + + local lines = vim.api.nvim_buf_get_lines(session.scratch_buf, 0, -1, false) + local content = table.concat(lines, "\n") + if #lines > 0 then + content = content .. "\n" + end + M.cleanup_session(file_path) - return { result = "closed" } + + return { content = content } end function M.get_context(preferred_bufnr) From 50eb99aea905f633fa412ce332436c9938b835f8 Mon Sep 17 00:00:00 2001 From: Erikson Kaszubowski Date: Mon, 18 May 2026 09:29:21 -0300 Subject: [PATCH 2/4] feat: add port file for discovery to align with specs --- cmd/gemini-server/main.go | 59 +++++++++++++++++++++++++++++++++++++-- lua/gemini/init.lua | 11 +++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/cmd/gemini-server/main.go b/cmd/gemini-server/main.go index bc86886..cbb869b 100644 --- a/cmd/gemini-server/main.go +++ b/cmd/gemini-server/main.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "os" + "path/filepath" "reflect" "time" "unsafe" @@ -26,6 +27,15 @@ var ( tokenFlag = flag.String("auth-token", "", "auth token to use (empty for random)") ) +type InitArgs struct { + WorkspacePaths []string `json:"workspacePaths" msgpack:"workspacePaths"` + IdeInfo struct { + Name string `json:"name" msgpack:"name"` + DisplayName string `json:"displayName" msgpack:"displayName"` + } `json:"ideInfo" msgpack:"ideInfo"` + ParentPid int `json:"parentPid" msgpack:"parentPid"` +} + func main() { flag.Parse() @@ -103,8 +113,8 @@ func main() { log.Fatalf("Failed to register diff_rejected handler: %v", err) } - if err := nvimClient.RegisterHandler("initialize", func() { - go runInitialization() + if err := nvimClient.RegisterHandler("initialize", func(args InitArgs) { + go runInitialization(args) }); err != nil { log.Fatalf("Failed to register initialize handler: %v", err) } @@ -224,7 +234,7 @@ func sendNotification(s *mcp.ServerSession, method string, params any) { } } -func runInitialization() { +func runInitialization(args InitArgs) { log.Println("Initializing MCP Server...") addr := fmt.Sprintf("127.0.0.1:%d", *portFlag) listener, err := net.Listen("tcp", addr) @@ -249,6 +259,49 @@ func runInitialization() { return nil }, nil) + // Create discovery file per IDE companion spec + tmpDir := os.TempDir() + ideDir := filepath.Join(tmpDir, "gemini", "ide") + if err := os.MkdirAll(ideDir, 0755); err != nil { + log.Printf("Failed to create ide directory: %v", err) + } + + workspaceSeparator := string(os.PathListSeparator) + workspacePath := "" + for i, p := range args.WorkspacePaths { + if i > 0 { + workspacePath += workspaceSeparator + } + workspacePath += p + } + + discoveryData := map[string]any{ + "port": port, + "workspacePath": workspacePath, + "authToken": authToken, + "ideInfo": args.IdeInfo, + } + + discoveryFileName := fmt.Sprintf("gemini-ide-server-%d-%d.json", args.ParentPid, port) + discoveryFilePath := filepath.Join(ideDir, discoveryFileName) + + data, err := json.MarshalIndent(discoveryData, "", " ") + if err != nil { + log.Printf("Failed to marshal discovery data: %v", err) + } else if err := os.WriteFile(discoveryFilePath, data, 0644); err != nil { + log.Printf("Failed to write discovery file: %v", err) + } else { + log.Printf("Created discovery file at %s", discoveryFilePath) + } + + defer func() { + if err := os.Remove(discoveryFilePath); err != nil && !os.IsNotExist(err) { + log.Printf("Failed to remove discovery file: %v", err) + } else { + log.Printf("Removed discovery file at %s", discoveryFilePath) + } + }() + log.Printf("MCP Server listening at http://127.0.0.1:%d", port) if err := http.Serve(listener, mcpHandler); err != nil { log.Printf("HTTP Server error: %v", err) diff --git a/lua/gemini/init.lua b/lua/gemini/init.lua index 8fdfd6a..597d021 100644 --- a/lua/gemini/init.lua +++ b/lua/gemini/init.lua @@ -104,7 +104,16 @@ local function start_server() -- Notify server to initialize once it's connected if job_id > 0 then - vim.rpcnotify(job_id, "initialize") + local init_args = { + workspacePaths = { vim.fn.getcwd() }, + ideInfo = { + name = "neovim", + displayName = "Neovim", + }, + parentPid = vim.fn.getpid(), + } + vim.rpcnotify(job_id, "initialize", init_args) + if last_opts and last_opts.debug then vim.notify("Gemini: Server initialized", vim.log.levels.DEBUG) end From f68b9cfc73cf67281b0857f9240e5ac92d2358dd Mon Sep 17 00:00:00 2001 From: Erikson Kaszubowski Date: Tue, 19 May 2026 09:54:23 -0300 Subject: [PATCH 3/4] feat(ide): align context tracking with specifications - Implement stateful LRU (Least Recently Used) manager in Lua - Use millisecond timestamps for accurate file ordering - Truncate selected text to 16KB to prevent RPC overhead - Add robust handling for file renames and deletions - Document TERM_PROGRAM workaround for CLI detection --- lua/gemini/context.lua | 135 +++++++++++++++++++++++++++++++++++++++++ lua/gemini/init.lua | 28 +++++++-- lua/gemini/tools.lua | 60 +----------------- 3 files changed, 159 insertions(+), 64 deletions(-) create mode 100644 lua/gemini/context.lua diff --git a/lua/gemini/context.lua b/lua/gemini/context.lua new file mode 100644 index 0000000..7a6ecb5 --- /dev/null +++ b/lua/gemini/context.lua @@ -0,0 +1,135 @@ +local M = {} + +local MAX_FILES = 10 +local MAX_SELECTED_TEXT_LENGTH = 16384 -- 16 KiB limit + +-- Array of { path = string, timestamp = number } +local open_files = {} + +local function is_file_buf(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + local buftype = vim.api.nvim_get_option_value("buftype", { buf = bufnr }) + local name = vim.api.nvim_buf_get_name(bufnr) + return buftype == "" and name ~= "" +end + +local function get_ms_timestamp() + -- vim.loop.hrtime() is in nanoseconds + return math.floor(vim.loop.hrtime() / 1000000) +end + +function M.add_or_move_to_front(bufnr) + if not is_file_buf(bufnr) then + return + end + + local path = vim.api.nvim_buf_get_name(bufnr) + local timestamp = get_ms_timestamp() + + -- Remove if already in list + for i, file in ipairs(open_files) do + if file.path == path then + table.remove(open_files, i) + break + end + end + + -- Add to front + table.insert(open_files, 1, { + path = path, + timestamp = timestamp, + bufnr = bufnr, + }) + + -- Enforce limit + if #open_files > MAX_FILES then + table.remove(open_files) + end +end + +function M.remove_file(path) + for i, file in ipairs(open_files) do + if file.path == path then + table.remove(open_files, i) + break + end + end +end + +function M.rename_file(old_path, new_path) + for _, file in ipairs(open_files) do + if file.path == old_path then + file.path = new_path + break + end + end +end + +function M.get_context(preferred_bufnr) + local current_buf = vim.api.nvim_get_current_buf() + local effective_active_buf = current_buf + + -- If we are in a special buffer (like a floating window or quickfix), + -- try to use the preferred buffer provided by the caller (last valid file buffer). + if not is_file_buf(current_buf) and preferred_bufnr and is_file_buf(preferred_bufnr) then + effective_active_buf = preferred_bufnr + end + + -- Always ensure the effective active buffer is at the front of our LRU + if is_file_buf(effective_active_buf) then + M.add_or_move_to_front(effective_active_buf) + end + + local context = { + workspaceState = { + openFiles = {}, + isTrusted = true, + }, + } + + for _, file in ipairs(open_files) do + -- Only include if buffer is still loaded and valid + if vim.api.nvim_buf_is_valid(file.bufnr) and vim.api.nvim_buf_is_loaded(file.bufnr) then + local file_info = { + path = file.path, + timestamp = file.timestamp, + isActive = (file.bufnr == effective_active_buf), + } + + if file_info.isActive then + -- Cursor position + local winid = vim.fn.bufwinid(file.bufnr) + if winid ~= -1 then + local cursor = vim.api.nvim_win_get_cursor(winid) + file_info.cursor = { + line = cursor[1], + character = cursor[2] + 1, + } + end + + -- Selected text (only if active) + local mode = vim.fn.mode() + if mode == "v" or mode == "V" or mode == "\22" then + local start_pos = vim.fn.getpos("v") + local end_pos = vim.fn.getpos(".") + local ok, region = pcall(vim.fn.getregion, start_pos, end_pos, { type = mode }) + if ok and region then + local selected = table.concat(region, "\n") + if #selected > MAX_SELECTED_TEXT_LENGTH then + selected = selected:sub(1, MAX_SELECTED_TEXT_LENGTH) + end + file_info.selectedText = selected + end + end + end + + table.insert(context.workspaceState.openFiles, file_info) + end + end + + return context +end + +return M diff --git a/lua/gemini/init.lua b/lua/gemini/init.lua index 597d021..f470e57 100644 --- a/lua/gemini/init.lua +++ b/lua/gemini/init.lua @@ -270,14 +270,15 @@ You are working alongside a human developer who may be **actively editing** the local current_buf = vim.api.nvim_get_current_buf() local buftype = vim.api.nvim_get_option_value("buftype", { buf = current_buf }) - if buftype ~= "" then - return + -- We only skip sending if the current buffer is invalid, + -- but we still use the last valid buffer to maintain context. + if buftype == "" then + last_valid_buf = current_buf end - last_valid_buf = current_buf - - local context = tools.get_context(last_valid_buf) + local context = require("gemini.context").get_context(last_valid_buf) vim.rpcnotify(job_id, "context_update", context) + if last_opts and last_opts.debug then local active_file = nil for _, file in ipairs(context.workspaceState.openFiles) do @@ -314,6 +315,23 @@ You are working alongside a human developer who may be **actively editing** the callback = debounced_send_context, }) + vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { + group = group, + callback = function(args) + require("gemini.context").remove_file(vim.api.nvim_buf_get_name(args.buf)) + debounced_send_context() + end, + }) + + vim.api.nvim_create_autocmd("BufFilePost", { + group = group, + callback = function(args) + -- This is triggered after :file or renaming + -- We don't have the old name easily here, but we can re-sync + debounced_send_context() + end, + }) + -- Define User Commands vim.api.nvim_create_user_command("GeminiBuild", function() M.build() diff --git a/lua/gemini/tools.lua b/lua/gemini/tools.lua index 0f9c326..526cc8a 100644 --- a/lua/gemini/tools.lua +++ b/lua/gemini/tools.lua @@ -299,65 +299,7 @@ function M.close_diff(file_path) end function M.get_context(preferred_bufnr) - local context = { - workspaceState = { - openFiles = {}, - isTrusted = true, - }, - } - - local current_buf = vim.api.nvim_get_current_buf() - local buffers = vim.api.nvim_list_bufs() - - -- Determine effective active buffer - local current_buftype = vim.api.nvim_get_option_value("buftype", { buf = current_buf }) - local effective_active_buf = current_buf - if current_buftype ~= "" and preferred_bufnr and vim.api.nvim_buf_is_valid(preferred_bufnr) then - effective_active_buf = preferred_bufnr - end - - for _, bufnr in ipairs(buffers) do - if vim.api.nvim_buf_is_loaded(bufnr) then - local name = vim.api.nvim_buf_get_name(bufnr) - local buftype = vim.api.nvim_get_option_value("buftype", { buf = bufnr }) - - if name ~= "" and buftype == "" then - local file_info = { - path = name, - timestamp = os.time(), - isActive = (bufnr == effective_active_buf), - } - - if file_info.isActive then - -- Attempt to find the window for the active buffer - local winid = vim.fn.bufwinid(bufnr) - if winid ~= -1 then - local cursor = vim.api.nvim_win_get_cursor(winid) - file_info.cursor = { - line = cursor[1], - character = cursor[2] + 1, - } - end - - -- Capture selected text - -- If currently in the active buffer, use standard methods. - local mode = vim.fn.mode() - if mode == "v" or mode == "V" or mode == "\22" then - local start_pos = vim.fn.getpos("v") - local end_pos = vim.fn.getpos(".") - local ok, region = pcall(vim.fn.getregion, start_pos, end_pos, { type = mode }) - if ok and region then - file_info.selectedText = table.concat(region, "\n") - end - end - end - - table.insert(context.workspaceState.openFiles, file_info) - end - end - end - - return context + return require("gemini.context").get_context(preferred_bufnr) end -- --- LSP & Treesitter Tools --- From 92467016e62128c4be8697a10592ebf0747be794 Mon Sep 17 00:00:00 2001 From: Erikson Kaszubowski Date: Tue, 19 May 2026 15:25:52 -0300 Subject: [PATCH 4/4] fix(ide): improve buffer management and diff application - add filereadable check before loading buffers in ensure_buffer - refine trailing newline handling in accept_diff and close_diff - simplify accept_diff by delegating buffer updates to RPC - refactor BufFilePost autocmd callback for brevity --- lua/gemini/init.lua | 6 +----- lua/gemini/tools.lua | 19 +++++++------------ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/lua/gemini/init.lua b/lua/gemini/init.lua index f470e57..7512212 100644 --- a/lua/gemini/init.lua +++ b/lua/gemini/init.lua @@ -325,11 +325,7 @@ You are working alongside a human developer who may be **actively editing** the vim.api.nvim_create_autocmd("BufFilePost", { group = group, - callback = function(args) - -- This is triggered after :file or renaming - -- We don't have the old name easily here, but we can re-sync - debounced_send_context() - end, + callback = debounced_send_context, }) -- Define User Commands diff --git a/lua/gemini/tools.lua b/lua/gemini/tools.lua index 526cc8a..400dd63 100644 --- a/lua/gemini/tools.lua +++ b/lua/gemini/tools.lua @@ -7,7 +7,10 @@ function M.ensure_buffer(file_path) file_path = vim.fn.fnamemodify(file_path, ":p") local bufnr = vim.fn.bufnr(file_path, true) if not vim.api.nvim_buf_is_loaded(bufnr) then - vim.fn.bufload(bufnr) + -- Only attempt to load if the file actually exists on disk + if vim.fn.filereadable(file_path) == 1 then + vim.fn.bufload(bufnr) + end -- Ensure filetype is set so LSP can attach if vim.api.nvim_get_option_value("filetype", { buf = bufnr }) == "" then vim.api.nvim_buf_call(bufnr, function() @@ -209,14 +212,10 @@ function M.accept_diff(file_path) local lines = vim.api.nvim_buf_get_lines(session.scratch_buf, 0, -1, false) local new_content = table.concat(lines, "\n") - if #lines > 0 then + if #lines > 0 and lines[#lines] ~= "" then new_content = new_content .. "\n" end - vim.api.nvim_buf_set_lines(session.bufnr, 0, -1, false, lines) - - vim.api.nvim_set_option_value("modified", false, { buf = session.bufnr }) - if session.job_id then vim.rpcnotify(session.job_id, "diff_accepted", { filePath = file_path, @@ -225,7 +224,7 @@ function M.accept_diff(file_path) end M.cleanup_session(file_path) - vim.notify("[gemini] Diff accepted.", vim.log.levels.INFO) + vim.notify("[gemini] Diff accepted. Applying changes...", vim.log.levels.INFO) end function M.reject_diff(file_path) @@ -289,7 +288,7 @@ function M.close_diff(file_path) local lines = vim.api.nvim_buf_get_lines(session.scratch_buf, 0, -1, false) local content = table.concat(lines, "\n") - if #lines > 0 then + if #lines > 0 and lines[#lines] ~= "" then content = content .. "\n" end @@ -298,10 +297,6 @@ function M.close_diff(file_path) return { content = content } end -function M.get_context(preferred_bufnr) - return require("gemini.context").get_context(preferred_bufnr) -end - -- --- LSP & Treesitter Tools --- local function uri_to_relative_path(uri)