Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 57 additions & 8 deletions cmd/gemini-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"reflect"
"time"
"unsafe"
Expand All @@ -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()

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
})

Expand Down
135 changes: 135 additions & 0 deletions lua/gemini/context.lua
Original file line number Diff line number Diff line change
@@ -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
36 changes: 30 additions & 6 deletions lua/gemini/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
]])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading