Skip to content

Commit db3c77d

Browse files
committed
Add nvim-cmp completion
Add autocompletion for commands and contexts using nvim-cmp. Introduces a new filetype called "eca-input" for the chat window that activates the completions.
1 parent beed598 commit db3c77d

File tree

5 files changed

+284
-0
lines changed

5 files changed

+284
-0
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---@param commands eca.ChatCommand
2+
---@return lsp.CompletionItem[]
3+
local function create_completion_items(commands)
4+
---@type lsp.CompletionItem[]
5+
local items = {}
6+
7+
if commands then
8+
for _, command in ipairs(commands) do
9+
---@type lsp.CompletionItem
10+
local item = {
11+
label = command.name or command.command,
12+
kind = vim.lsp.protocol.CompletionItemKind.Function,
13+
detail = command.description or ("ECA command: " .. (command.name or command.command)),
14+
documentation = command.help and {
15+
kind = "markdown",
16+
value = command.help,
17+
} or nil,
18+
insertText = command.name or command.command,
19+
}
20+
table.insert(items, item)
21+
end
22+
end
23+
24+
return items
25+
end
26+
27+
---@param s string
28+
---@return string
29+
local function get_query(s)
30+
local match = s:match("^>?%s*/(.*)$")
31+
return match
32+
end
33+
34+
-- Query server for available commands
35+
local function query_server_commands(query, callback)
36+
local eca = require("eca")
37+
if not eca.server or not eca.server:is_running() then
38+
callback({})
39+
return
40+
end
41+
42+
eca.server:send_request("chat/queryCommands", { query = query }, function(err, result)
43+
if err then
44+
callback({})
45+
return
46+
end
47+
48+
local items = create_completion_items(result.commands)
49+
callback(items)
50+
end)
51+
end
52+
53+
local source = {}
54+
55+
source.new = function()
56+
return setmetatable({ cache = {} }, { __index = source })
57+
end
58+
59+
source.get_trigger_characters = function()
60+
return { "/" }
61+
end
62+
63+
source.is_available = function()
64+
return vim.bo.filetype == "eca-input"
65+
end
66+
67+
---@diagnostic disable-next-line: unused-local
68+
source.complete = function(self, _, callback)
69+
local logger = require("eca.logger")
70+
-- Only complete if we're typing a command (starts with /)
71+
local line = vim.api.nvim_get_current_line()
72+
local query = get_query(line)
73+
74+
-- Only provide command completions when we have / followed by word characters or at word boundary
75+
if query then
76+
local bufnr = vim.api.nvim_get_current_buf()
77+
78+
if self.cache[bufnr] and self.cache[bufnr][query] then
79+
callback({ items = self.cache[bufnr][query], isIncomplete = false })
80+
else
81+
logger.debug("not cached")
82+
query_server_commands(query, function(items)
83+
callback({
84+
items = items,
85+
isIncomplete = false,
86+
})
87+
self.cache[bufnr] = {}
88+
self.cache[bufnr][query] = items
89+
end)
90+
end
91+
else
92+
callback({
93+
items = {},
94+
isIncomplete = false,
95+
})
96+
end
97+
end
98+
return source

lua/eca/completion/cmp/context.lua

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
local Logger = require("eca.logger")
2+
local cmp = require("cmp")
3+
---@param context eca.ChatContext
4+
---@return lsp.CompletionItem
5+
local function as_completion_item(context)
6+
---@type lsp.CompletionItem
7+
---@diagnostic disable-next-line: missing-fields
8+
local item = {}
9+
if context.type == "file" then
10+
item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":."))
11+
item.kind = cmp.lsp.CompletionItemKind.File
12+
item.data = {
13+
context_item = context,
14+
}
15+
elseif context.type == "directory" then
16+
item.label = string.format("@%s", vim.fn.fnamemodify(context.path, ":."))
17+
item.kind = cmp.lsp.CompletionItemKind.Folder
18+
elseif context.type == "web" then
19+
item.label = context.url
20+
item.kind = cmp.lsp.CompletionItemKind.File
21+
elseif context.type == "repoMap" then
22+
item.label = "repoMap"
23+
item.kind = cmp.lsp.CompletionItemKind.Module
24+
item.detail = "Summary view of workspace files."
25+
elseif context.type == "mcpResource" then
26+
item.label = string.format("%s:%s", context.server, context.name)
27+
item.kind = cmp.lsp.CompletionItemKind.Struct
28+
item.detail = context.description
29+
end
30+
if not item.label then
31+
return {}
32+
end
33+
return item
34+
end
35+
36+
-- Query server for available contexts
37+
-- @param query string
38+
--
39+
local function query_server_contexts(query, callback)
40+
local eca = require("eca")
41+
if not eca.server or not eca.server:is_running() then
42+
callback({})
43+
return
44+
end
45+
46+
eca.server:send_request("chat/queryContext", { query = query }, function(err, result)
47+
Logger.debug("chat/queryContext query " .. vim.inspect({ query = query }))
48+
if err then
49+
Logger.debug("chat/queryContext err: " .. err)
50+
callback({})
51+
return
52+
end
53+
54+
local items = vim.iter(result.contexts):map(as_completion_item):totable()
55+
callback({ items = items, isIncomplete = true })
56+
end)
57+
end
58+
59+
local source = {}
60+
61+
function source.new()
62+
return setmetatable({}, { __index = source })
63+
end
64+
65+
function source:get_trigger_characters()
66+
return { "@" }
67+
end
68+
69+
function source:get_keyword_pattern()
70+
return [[\k\+]]
71+
end
72+
73+
function source:is_available()
74+
return vim.bo.filetype == "eca-input"
75+
end
76+
77+
---@param cursor_line string
78+
---@param cursor_position lsp.Position|vim.Position
79+
---@return string
80+
local function get_query(cursor_line, cursor_position)
81+
local before_cursor = cursor_line:sub(1, cursor_position.col)
82+
---@type string[]
83+
local matches = {}
84+
local it = before_cursor:gmatch("@([%w%./_\\%-~]*)")
85+
for match in it do
86+
table.insert(matches, match)
87+
end
88+
return matches[#matches]
89+
end
90+
91+
---@param params cmp.SourceCompletionApiParams
92+
---@diagnostic disable-next-line: unused-local
93+
function source:complete(params, callback)
94+
local query = get_query(params.context.cursor_line, params.context.cursor)
95+
if query then
96+
query_server_contexts(query, function(items)
97+
callback(items)
98+
end)
99+
end
100+
end
101+
102+
--- Taken from https://github.com/hrsh7th/cmp-path/blob/9a16c8e5d0be845f1d1b64a0331b155a9fe6db4d/lua/cmp_path/init.lua
103+
--- Show a small preview of file contexft items in the documentation window.
104+
---@param data eca.ChatContext
105+
---@return lsp.MarkupContent
106+
source._get_documentation = function(_, data, count)
107+
if data and data.path then
108+
local filename = data.path
109+
local binary = assert(io.open(data.path, "rb"))
110+
local first_kb = binary:read(1024)
111+
if first_kb and first_kb:find("\0") then
112+
return { kind = vim.lsp.protocol.MarkupKind.PlainText, value = "binary file" }
113+
end
114+
115+
local content = io.lines(data.path)
116+
117+
--- Try to support line ranges, I don't know if this works or not yet
118+
local start = data.lines_range and data.lines_range.start or 1
119+
local last = data.lines_range and data.lines_range["end"] or count
120+
local skip_lines = start - 1
121+
local take_lines = last - start
122+
local contents = vim.iter(content):skip(skip_lines):take(take_lines):totable()
123+
124+
local filetype = vim.filetype.match({ filename = filename })
125+
if not filetype then
126+
return { kind = vim.lsp.protocol.MarkupKind.PlainText, value = table.concat(contents, "\n") }
127+
end
128+
129+
table.insert(contents, 1, "```" .. filetype)
130+
table.insert(contents, "```")
131+
return { kind = vim.lsp.protocol.MarkupKind.Markdown, value = table.concat(contents, "\n") }
132+
end
133+
return {}
134+
end
135+
136+
---@param completion_item lsp.CompletionItem
137+
function source:resolve(completion_item, callback)
138+
if completion_item.data then
139+
local context_item = completion_item.data.context_item
140+
---@cast context_item eca.ChatContext
141+
if context_item.type == "file" then
142+
completion_item.documentation = self:_get_documentation(context_item, 20)
143+
end
144+
callback(completion_item)
145+
end
146+
end
147+
148+
return source

lua/eca/completion/cmp/init.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
local cmp = require("cmp")
2+
3+
vim.api.nvim_create_autocmd("FileType", {
4+
pattern = "eca-input",
5+
callback = function()
6+
cmp.register_source("eca_commands", require("eca.completion.cmp.commands").new())
7+
cmp.register_source("eca_contexts", require("eca.completion.cmp.context").new())
8+
cmp.setup.filetype("eca-input", {
9+
sources = {
10+
{ name = "eca_contexts" },
11+
{ name = "eca_commands" },
12+
},
13+
})
14+
-- returning true will remove this autocmd
15+
-- now that the completion sources are registered
16+
return true
17+
end,
18+
})

lua/eca/sidebar.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ function M:_create_containers()
367367
size = { height = input_height },
368368
buf_options = vim.tbl_deep_extend("force", base_buf_options, {
369369
modifiable = true,
370+
filetype = "eca-input",
370371
}),
371372
win_options = vim.tbl_deep_extend("force", base_win_options, {
372373
statusline = " ",

lua/eca/types.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---@meta
2+
---@class eca.ChatContext
3+
---@field type string
4+
---@field path? string
5+
---@field lines_range? {start: integer, end: integer}
6+
---@field url? string
7+
---@field uri? string
8+
---@field name? string
9+
---@field description? string
10+
---@field mime_type? string
11+
---@field server string
12+
---
13+
---
14+
---@class eca.ChatCommand
15+
---@field name string
16+
---@field description string
17+
---@field help string
18+
---@field type "mcp-prompt"|"native"
19+
---@field arguments {name: string, description?: string, required: boolean}[]

0 commit comments

Comments
 (0)