diff --git a/cmd/gemini-server/main.go b/cmd/gemini-server/main.go index 75a1a46..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) @@ -323,11 +376,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/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 5d69212..7512212 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 @@ -208,6 +217,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. ]]) @@ -260,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 @@ -304,6 +315,19 @@ 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 = debounced_send_context, + }) + -- 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 1adbcf0..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() @@ -207,22 +210,21 @@ 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) - - -- 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 }) + local lines = vim.api.nvim_buf_get_lines(session.scratch_buf, 0, -1, false) + local new_content = table.concat(lines, "\n") + if #lines > 0 and lines[#lines] ~= "" then + new_content = new_content .. "\n" + end if session.job_id then vim.rpcnotify(session.job_id, "diff_accepted", { filePath = file_path, - content = session.new_content, + content = new_content, }) 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) @@ -278,70 +280,21 @@ end function M.close_diff(file_path) file_path = vim.fn.fnamemodify(file_path, ":p") - M.cleanup_session(file_path) - return { result = "closed" } -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() + local session = diff_sessions[file_path] - -- 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 + if not session then + return { content = "" } 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 + local lines = vim.api.nvim_buf_get_lines(session.scratch_buf, 0, -1, false) + local content = table.concat(lines, "\n") + if #lines > 0 and lines[#lines] ~= "" then + content = content .. "\n" end - return context + M.cleanup_session(file_path) + + return { content = content } end -- --- LSP & Treesitter Tools ---