Skip to content

Commit 8602a50

Browse files
authored
feat(chat): add copilot usage statistics with gS keymap (olimorris#1677)
1 parent e18a9bf commit 8602a50

File tree

7 files changed

+213
-4
lines changed

7 files changed

+213
-4
lines changed

doc/codecompanion.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2144,6 +2144,7 @@ The keymaps available to the user in normal mode are:
21442144
- `gR` to go to the file under cursor. If the file is already opened, it’ll jump
21452145
to the existing window. Otherwise, it’ll be opened in a new tab.
21462146
- `gs` to toggle the system prompt on/off
2147+
- `gS` to show copilot usage stats
21472148
- `gta` to toggle auto tool mode
21482149
- `gx` to clear the chat buffer’s contents
21492150
- `gy` to yank the last codeblock in the chat buffer

doc/usage/chat-buffer/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ The keymaps available to the user in normal mode are:
8585
- `gR` to go to the file under cursor. If the file is already opened, it'll jump
8686
to the existing window. Otherwise, it'll be opened in a new tab.
8787
- `gs` to toggle the system prompt on/off
88+
- `gS` to show copilot usage stats
8889
- `gta` to toggle auto tool mode
8990
- `gx` to clear the chat buffer's contents
9091
- `gy` to yank the last codeblock in the chat buffer
9192
- `[[` to move to the previous header
9293
- `]]` to move to the next header
9394
- `{` to move to the previous chat
9495
- `}` to move to the next chat
95-

lua/codecompanion/adapters/copilot.lua

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,135 @@ local function get_models(self, opts)
226226
return models
227227
end
228228

229+
---Get Copilot usage statistics
230+
---@return table|nil
231+
local function get_copilot_stats()
232+
local dummy_adapter = { url = "" }
233+
if not get_and_authorize_token(dummy_adapter) then
234+
return nil
235+
end
236+
237+
log:debug("Fetching Copilot usage statistics")
238+
239+
local ok, response = pcall(function()
240+
return curl.get("https://api.github.com/copilot_internal/user", {
241+
sync = true,
242+
headers = {
243+
Authorization = "Bearer " .. _oauth_token,
244+
Accept = "*/*",
245+
["User-Agent"] = "CodeCompanion.nvim",
246+
},
247+
insecure = config.adapters.opts.allow_insecure,
248+
proxy = config.adapters.opts.proxy,
249+
})
250+
end)
251+
if not ok then
252+
log:error("Could not get Copilot stats: %s", response)
253+
return nil
254+
end
255+
256+
local ok, json = pcall(vim.json.decode, response.body)
257+
if not ok then
258+
log:error("Error parsing Copilot stats response: %s", response.body)
259+
return nil
260+
end
261+
262+
return json
263+
end
264+
265+
---Show Copilot usage statistics in a floating window
266+
---@return nil
267+
local function show_copilot_stats()
268+
local stats = get_copilot_stats()
269+
if not stats then
270+
return vim.notify("Could not retrieve Copilot stats", vim.log.levels.ERROR)
271+
end
272+
273+
local lines = {}
274+
local ui = require("codecompanion.utils.ui")
275+
table.insert(lines, "# 󰾞 GitHub Copilot Usage Statistics 󰾞 ")
276+
table.insert(lines, "")
277+
278+
if stats.quota_snapshots.premium_interactions then
279+
local premium = stats.quota_snapshots.premium_interactions
280+
table.insert(lines, "##  Premium Interactions")
281+
local used = premium.entitlement - premium.remaining
282+
local usage_percent = premium.entitlement > 0 and (used / premium.entitlement * 100) or 0
283+
table.insert(lines, string.format(" - Used: %d / %d (%.1f%%)", used, premium.entitlement, usage_percent))
284+
table.insert(lines, string.format(" - Remaining: %d", premium.remaining))
285+
table.insert(lines, string.format(" - Percentage: %.1f%%", premium.percent_remaining))
286+
if premium.unlimited then
287+
table.insert(lines, " - Status: Unlimited ✨")
288+
else
289+
table.insert(lines, " - Status: Limited")
290+
end
291+
table.insert(lines, "")
292+
end
293+
294+
if stats.quota_snapshots.chat then
295+
local chat = stats.quota_snapshots.chat
296+
table.insert(lines, "## 󰭹 Chat")
297+
if chat.unlimited then
298+
table.insert(lines, " - Status: Unlimited ✨")
299+
else
300+
local used = chat.entitlement - chat.remaining
301+
local usage_percent = chat.entitlement > 0 and (used / chat.entitlement * 100) or 0
302+
table.insert(lines, string.format(" - Used: %d / %d (%.1f%%)", used, chat.entitlement, usage_percent))
303+
end
304+
table.insert(lines, "")
305+
end
306+
307+
if stats.quota_snapshots.completions then
308+
local completions = stats.quota_snapshots.completions
309+
table.insert(lines, "##  Completions")
310+
if completions.unlimited then
311+
table.insert(lines, " - Status: Unlimited ✨")
312+
else
313+
local used = completions.entitlement - completions.remaining
314+
local usage_percent = completions.entitlement > 0 and (used / completions.entitlement * 100) or 0
315+
table.insert(lines, string.format(" - Used: %d / %d (%.1f%%)", used, completions.entitlement, usage_percent))
316+
end
317+
end
318+
if stats.quota_reset_date then
319+
table.insert(lines, "")
320+
table.insert(lines, string.format("> Quota resets on: %s", stats.quota_reset_date))
321+
table.insert(lines, "")
322+
end
323+
324+
-- Create floating window
325+
local float_opts = {
326+
title = "󰍘 Copilot Stats",
327+
lock = true,
328+
relative = "editor",
329+
row = "center",
330+
col = "center",
331+
window = {
332+
width = 43,
333+
height = math.min(#lines + 2, 20),
334+
},
335+
ignore_keymaps = false,
336+
}
337+
local _, winnr = ui.create_float(lines, float_opts)
338+
339+
local function get_usage_highlight(usage_percent)
340+
if usage_percent >= 80 then
341+
return "Error"
342+
else
343+
return "MoreMsg"
344+
end
345+
end
346+
vim.api.nvim_win_call(winnr, function()
347+
-- Usage percentages with color coding
348+
local premium = stats.quota_snapshots.premium_interactions
349+
if premium and not premium.unlimited then
350+
local used = premium.entitlement - premium.remaining
351+
local usage_percent = premium.entitlement > 0 and (used / premium.entitlement * 100) or 0
352+
local highlight = get_usage_highlight(usage_percent)
353+
vim.fn.matchadd(highlight, string.format(" - Used: %d / %d (%.1f%%)", used, premium.entitlement, usage_percent))
354+
end
355+
end)
356+
end
357+
229358
---@class Copilot.Adapter: CodeCompanion.Adapter
230359
return {
231360
name = "copilot",
@@ -256,6 +385,12 @@ return {
256385
["Copilot-Integration-Id"] = "vscode-chat",
257386
["Editor-Version"] = "Neovim/" .. vim.version().major .. "." .. vim.version().minor .. "." .. vim.version().patch,
258387
},
388+
get_copilot_stats = function()
389+
return get_copilot_stats()
390+
end,
391+
show_copilot_stats = function()
392+
return show_copilot_stats()
393+
end,
259394
handlers = {
260395
---Check for a token before starting the request
261396
---@param self CodeCompanion.Adapter

lua/codecompanion/config.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,12 @@ local defaults = {
433433
callback = "keymaps.goto_file_under_cursor",
434434
description = "Open the file under cursor in a new tab.",
435435
},
436+
copilot_stats = {
437+
modes = { n = "gS" },
438+
index = 20,
439+
callback = "keymaps.copilot_stats",
440+
description = "Show Copilot usage statistics",
441+
},
436442
},
437443
opts = {
438444
blank_prompt = "", -- The prompt to use when the user doesn't provide a prompt

lua/codecompanion/strategies/chat/keymaps.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,4 +642,18 @@ M.goto_file_under_cursor = {
642642
end,
643643
}
644644

645+
M.copilot_stats = {
646+
desc = "Show Copilot usage statistics",
647+
callback = function(chat)
648+
if chat.adapter.name ~= "copilot" then
649+
return util.notify("Copilot stats are only available when using the Copilot adapter", vim.log.levels.WARN)
650+
end
651+
if chat.adapter.show_copilot_stats then
652+
chat.adapter.show_copilot_stats()
653+
else
654+
util.notify("Copilot stats function not available", vim.log.levels.ERROR)
655+
end
656+
end,
657+
}
658+
645659
return M

lua/codecompanion/utils/ui.lua

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,25 @@ M.create_float = function(lines, opts)
1717
local bufnr = opts.bufnr or api.nvim_create_buf(false, true)
1818

1919
require("codecompanion.utils").set_option(bufnr, "filetype", opts.filetype or "codecompanion")
20+
-- Calculate center position if not specified
21+
local row = opts.row or window.row or 10
22+
local col = opts.col or window.col or 0
23+
if row == "center" then
24+
row = math.floor((vim.o.lines - height) / 2)
25+
end
26+
if col == "center" then
27+
col = math.floor((vim.o.columns - width) / 2)
28+
end
2029

2130
local winnr = api.nvim_open_win(bufnr, true, {
2231
relative = opts.relative or "cursor",
23-
border = "single",
32+
-- thanks to @mini.nvim for this, it's for >= 0.11, to respect users winborder style
33+
border = (vim.fn.exists("+winborder") == 1 and vim.o.winborder ~= "") and vim.o.winborder or "single",
2434
width = width,
2535
height = height,
2636
style = "minimal",
27-
row = 10,
28-
col = 0,
37+
row = row,
38+
col = col,
2939
title = opts.title or "Options",
3040
title_pos = "center",
3141
})

tests/adapters/test_copilot.lua

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,47 @@ T["Copilot adapter"]["No Streaming"]["can output for the inline assistant"] = fu
127127
)
128128
end
129129

130+
T["Stats"] = new_set()
131+
132+
T["Stats"]["can calculate usage percentages correctly"] = function()
133+
local entitlement, remaining = 300, 250
134+
local used = entitlement - remaining
135+
local usage_percent = entitlement > 0 and (used / entitlement * 100) or 0
136+
h.eq(50, used)
137+
-- 50/300 * 100 = 16.666... so we need to check the rounded value
138+
h.eq(16.7, math.floor(usage_percent * 10 + 0.5) / 10)
139+
140+
local zero_entitlement = 0
141+
local zero_percent = zero_entitlement > 0 and (0 / zero_entitlement * 100) or 0
142+
h.eq(0, zero_percent)
143+
144+
-- Test full usage
145+
local full_entitlement, no_remaining = 100, 0
146+
local full_used = full_entitlement - no_remaining
147+
local full_percent = full_entitlement > 0 and (full_used / full_entitlement * 100) or 0
148+
h.eq(100, full_used)
149+
h.eq(100.0, full_percent)
150+
end
151+
152+
T["Stats"]["can determine correct highlight colors based on usage"] = function()
153+
local function get_usage_highlight(usage_percent)
154+
if usage_percent >= 80 then
155+
return "Error"
156+
else
157+
return "MoreMsg"
158+
end
159+
end
160+
161+
-- Test low usage (green)
162+
h.eq("MoreMsg", get_usage_highlight(16.7))
163+
h.eq("MoreMsg", get_usage_highlight(50))
164+
h.eq("MoreMsg", get_usage_highlight(79.9))
165+
-- Test high usage (red)
166+
h.eq("Error", get_usage_highlight(80))
167+
h.eq("Error", get_usage_highlight(85))
168+
h.eq("Error", get_usage_highlight(100))
169+
170+
h.eq("MoreMsg", get_usage_highlight(0))
171+
end
172+
130173
return T

0 commit comments

Comments
 (0)