summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThePrimeAgain <theprimeagain@theprimeagain.com>2026-01-15 08:23:22 -0700
committerThePrimeAgain <theprimeagain@theprimeagain.com>2026-01-15 08:23:22 -0700
commit4e24d093d50177e299823703759a5e276d198170 (patch)
tree5fa8506968f25bd7419256a5cb5ca4407cb85a0b
parentfc4ca85249de2f0311a62c6f17442996f8f79034 (diff)
downloada4-4e24d093d50177e299823703759a5e276d198170.tar.xz
a4-4e24d093d50177e299823703759a5e276d198170.zip
cmp: working through creating a cmp source
-rw-r--r--lua/99/agents/helpers.lua97
-rw-r--r--lua/99/extensions/cmp.lua109
-rw-r--r--lua/99/extensions/init.lua5
-rw-r--r--lua/99/test/agent.helpers_spec.lua46
-rw-r--r--lua/99/window/init.lua90
-rw-r--r--scratch/refresh.lua142
6 files changed, 287 insertions, 202 deletions
diff --git a/lua/99/agents/helpers.lua b/lua/99/agents/helpers.lua
index 89e0f5d..a5000cb 100644
--- a/lua/99/agents/helpers.lua
+++ b/lua/99/agents/helpers.lua
@@ -18,101 +18,4 @@ function M.ls(dir)
return rules
end
---- @class _99.Agents.FuzzyState.Selected
---- @field name string
---- @field index number
-
---- @class _99.Agents.FuzzyState
---- @field selected _99.Agents.FuzzyState.Selected[]
---- @field input string
---- @field possible string[]
---- @field case_sensitive boolean
-
---- @param input string
----@param state _99.Agents.FuzzyState
----@return _99.Agents.FuzzyState
-local function play_input(input, state)
- local current_input = ""
- state = M.create_state(state.possible, state.case_sensitive)
- for i = 1, #input do
- current_input = current_input .. input:sub(i, i)
- state = M.fuzzy_match(current_input, state)
- end
- return state
-end
-
-local function clone(state)
- return {
- input = state.input,
- selected = state.selected,
- possible = state.possible,
- case_sensitive = state.case_sensitive,
- }
-end
-
---- @param possible string[]
---- @param case_sensitive boolean | nil
---- @return _99.Agents.FuzzyState
-function M.create_state(possible, case_sensitive)
- --- @type _99.Agents.FuzzyState
- local state = {
- input = "",
- selected = {},
- possible = possible,
- case_sensitive = case_sensitive or false,
- }
- for _, p in ipairs(state.possible) do
- table.insert(state.selected, {
- name = p,
- index = 1,
- })
- end
- return state
-end
-
---- @param input string
----@param state _99.Agents.FuzzyState
-function M.fuzzy_match(input, state)
- if state.input == input then
- return state
- end
-
- state = clone(state)
- if
- input:sub(1, #state.input) ~= state.input
- or #state.input + 2 <= #input
- then
- return play_input(input, state)
- end
-
- -- search through each selected item
- local last_char = input:sub(#input, #input)
- if not state.case_sensitive then
- last_char = last_char:lower()
- end
-
- local new_selected = {}
- for _, selected in ipairs(state.selected) do
- local name = selected.name
- if not state.case_sensitive then
- name = name:lower()
- end
-
- -- check if the character at the current index matches the last input character
- -- find the next occurrence of last_char starting from selected.index
- local next_index = name:find(last_char, selected.index, true)
- if next_index then
- table.insert(new_selected, {
- name = selected.name,
- index = next_index + 1,
- })
- end
- end
-
- state.selected = new_selected
- state.input = input
-
- return state
-end
-
return M
diff --git a/lua/99/extensions/cmp.lua b/lua/99/extensions/cmp.lua
new file mode 100644
index 0000000..e55ae36
--- /dev/null
+++ b/lua/99/extensions/cmp.lua
@@ -0,0 +1,109 @@
+--- @class CmpSource
+--- @field _99 _99.State
+--- @field items string[]
+local CmpSource = {}
+CmpSource.__index = CmpSource
+
+local SOURCE = "99"
+
+--- @param _99 _99.State
+function CmpSource.new(_99)
+ return setmetatable({}, CmpSource)
+end
+
+function CmpSource:is_available()
+ return true
+end
+
+function CmpSource:get_debug_name()
+ return SOURCE
+end
+
+function CmpSource:get_keyword_pattern()
+ return [[@\k\+]]
+end
+
+function CmpSource:get_trigger_characters()
+ return { "@" }
+end
+
+--- @class CompletionItem
+--- @field label string
+--- @field kind number kind is optional but gives icons / categories
+--- @field documentation string can be a string or markdown table
+--- @field detail string detail shows a right-side hint
+
+--- @class Completion
+--- @field items CompletionItem[]
+--- @field isIncomplete boolean -
+-- true: I might return more if user types more
+-- false: this result set is complete
+function CmpSource:complete(params, callback)
+ local cmp = require("cmp")
+ local before = params.context.cursor_before_line or ""
+ local prefix = before:match("(%w+)$") or ""
+
+ local items = {} --[[ @as CompletionItem[] ]]
+
+ print("complete: context", vim.inspect(params.context))
+
+ callback({
+ items = items,
+ isIncomplete = true,
+ })
+end
+
+-- resolve(completion_item, callback) (optional)
+-- Some sources return lightweight items first, then fill in heavy fields
+-- only when the user selects an item.
+--
+-- For example:
+-- - fetch docs lazily
+-- - compute expensive detail text
+--
+-- If you don’t need it, omit it.
+function CmpSource:resolve(completion_item, callback)
+ -- You can modify completion_item here.
+ callback(completion_item)
+end
+
+-- execute(completion_item, callback) (optional)
+-- Called when the item is confirmed, if the item contains an "command" field
+-- or if your source wants to perform side-effects.
+--
+-- Examples:
+-- - insert import statements (usually via additionalTextEdits instead)
+-- - open a snippet, run something, etc.
+function CmpSource:execute(completion_item, callback)
+ callback(completion_item)
+end
+
+--- @type CmpSource | nil
+local source = nil
+
+--- @param _99 _99.State
+local function init_for_buffer(_99)
+ local cmp = require("cmp")
+ cmp.setup.buffer({
+ sources = {
+ { name = "my_source" },
+ },
+ })
+end
+
+--- @param _99 _99.State
+local function init(_99)
+ assert(source == nil, "the source must be nil when calling init on an completer")
+
+ local cmp = require("cmp")
+ source = CmpSource.new(_99)
+ cmp.register_source(SOURCE, source)
+end
+
+local function refresh_state(_99) end
+
+return {
+ init_for_buffer = init_for_buffer,
+ init = init,
+ refresh_state = refresh_state,
+}
diff --git a/lua/99/extensions/init.lua b/lua/99/extensions/init.lua
new file mode 100644
index 0000000..94190d6
--- /dev/null
+++ b/lua/99/extensions/init.lua
@@ -0,0 +1,5 @@
+local M = {}
+
+--- @param _99 _99.State
+function M.init_buffer(_99)
+end
diff --git a/lua/99/test/agent.helpers_spec.lua b/lua/99/test/agent.helpers_spec.lua
index f59870a..517f794 100644
--- a/lua/99/test/agent.helpers_spec.lua
+++ b/lua/99/test/agent.helpers_spec.lua
@@ -2,51 +2,5 @@
local helpers = require("99.agents.helpers")
local eq = assert.are.same
---- @param selected _99.Agents.FuzzyState.Selected[]
---- @return string[]
-local function n(selected)
- return vim.tbl_map(function(item) return item.name end, selected)
-end
-
-local possible = {
- "react-hooks",
- "react-state",
- "redux",
- "recoil",
- "rust-analyzer",
-}
-
describe("99.agents.helpers", function()
- it("fuzzy matching", function()
- local state = helpers.create_state(possible, false)
- eq(5, #state.selected)
-
- state = helpers.fuzzy_match("rea", state)
- eq({ "react-hooks", "react-state", }, n(state.selected))
-
- state = helpers.fuzzy_match("rh", state)
- eq({ "react-hooks" }, n(state.selected))
-
- state = helpers.fuzzy_match("rst", state)
- eq({ "react-state", "rust-analyzer" }, n(state.selected))
-
- state = helpers.fuzzy_match("xyz", state)
- eq({}, state.selected)
- end)
-
- it("fuzzy matching iterative", function()
- local state = helpers.create_state(possible, false)
-
- state = helpers.fuzzy_match("r", state)
- eq(possible, n(state.selected))
-
- state = helpers.fuzzy_match("re", state)
- eq(possible, n(state.selected))
-
- state = helpers.fuzzy_match("rea", state)
- eq({ "react-hooks", "react-state", }, n(state.selected))
-
- state = helpers.fuzzy_match("re", state)
- eq(possible, n(state.selected))
- end)
end)
diff --git a/lua/99/window/init.lua b/lua/99/window/init.lua
index 6eb9d7d..8f45433 100644
--- a/lua/99/window/init.lua
+++ b/lua/99/window/init.lua
@@ -4,6 +4,7 @@ local M = {
active_windows = {},
}
local nsid = vim.api.nvim_create_namespace("99.window.error")
+local nvim_win_is_valid = vim.api.nvim_win_is_valid
--- @class _99.window.Config
--- @field width number
@@ -167,26 +168,12 @@ end
--- @param window _99.window.Window
local function window_close(window)
- if vim.api.nvim_win_is_valid(window.win_id) then
+ if nvim_win_is_valid(window.win_id) then
vim.api.nvim_win_close(window.win_id, true)
end
if vim.api.nvim_buf_is_valid(window.buf_id) then
vim.api.nvim_buf_delete(window.buf_id, { force = true })
end
-
- local found = false
- for i, w in ipairs(M.active_windows) do
- if w.buf_id == window.buf_id and w.win_id == window.win_id then
- found = true
- table.remove(M.active_windows, i)
- break
- end
- end
-
- assert(
- found,
- "somehow we have closed a window that did not belong to the windows library"
- )
end
--- @param text string
@@ -207,7 +194,9 @@ function M.display_cancellation_message(text)
})
vim.defer_fn(function()
- window_close(window)
+ if nvim_win_is_valid(window.win_id) then
+ M.clear_active_popups()
+ end
end, 5000)
return window
@@ -268,14 +257,6 @@ local function set_defaul_win_options(win, name)
vim.bo[win.buf_id].swapfile = false
end
---- @param win _99.window.Window
-local function set_scratch_opts(win)
- vim.bo[win.buf_id].buftype = "nofile"
- vim.bo[win.buf_id].bufhidden = "wipe"
- vim.bo[win.buf_id].swapfile = false
- vim.wo[win.win_id].number = false
-end
-
--- @param cb fun(success: boolean, result: string): nil
--- @param opts {}
function M.capture_input(cb, opts)
@@ -283,37 +264,12 @@ function M.capture_input(cb, opts)
M.clear_active_popups()
local config = create_centered_window()
- local help = create_window_inside(config)
- local behaviors = create_window_inside(config, 1)
-
local win = create_floating_window(config, {
title = " 99 Prompt ",
border = "rounded",
})
- local help_win = create_floating_window(help, {
- title = "",
- border = "none",
- zindex = 60,
- })
-
- local behaviors_win = create_floating_window(behaviors, {
- title = "",
- border = "none",
- zindex = 60,
- })
-
set_defaul_win_options(win, "99-prompt")
-
- -- Use nofile for helper windows so they don't prompt about unsaved changes
- vim.api.nvim_buf_set_name(help_win.buf_id, "99-prompt-help")
- set_scratch_opts(behaviors_win)
-
- vim.api.nvim_buf_set_name(behaviors_win.buf_id, "99-prompt-behaviors")
- set_scratch_opts(behaviors_win)
-
- vim.api.nvim_buf_set_lines(help_win.buf_id, 0, 1, false, {"help"})
- vim.api.nvim_buf_set_lines(behaviors_win.buf_id, 0, 1, false, {"beh"})
vim.api.nvim_set_current_win(win.win_id)
local group = vim.api.nvim_create_augroup(
@@ -321,10 +277,25 @@ function M.capture_input(cb, opts)
{ clear = true }
)
+ vim.api.nvim_create_autocmd("BufLeave", {
+ group = group,
+ buffer = win.buf_id,
+ callback = function()
+ if nvim_win_is_valid(win.win_id) then
+ vim.api.nvim_set_current_win(win.win_id)
+ else
+ M.clear_active_popups()
+ end
+ end,
+ })
+
vim.api.nvim_create_autocmd("BufWriteCmd", {
group = group,
buffer = win.buf_id,
callback = function()
+ if not nvim_win_is_valid(win.win_id) then
+ return
+ end
local lines = vim.api.nvim_buf_get_lines(win.buf_id, 0, -1, false)
local result = table.concat(lines, "\n")
M.clear_active_popups()
@@ -336,23 +307,36 @@ function M.capture_input(cb, opts)
group = group,
buffer = win.buf_id,
callback = function()
+ if not nvim_win_is_valid(win.win_id) then
+ return
+ end
vim.api.nvim_del_augroup_by_id(group)
end,
})
+ vim.api.nvim_create_autocmd("WinClosed", {
+ group = group,
+ pattern = tostring(win.win_id),
+ callback = function()
+ if not nvim_win_is_valid(win.win_id) then
+ return
+ end
+ M.clear_active_popups()
+ cb(false, "")
+ end,
+ })
+
vim.keymap.set("n", "q", function()
M.clear_active_popups()
cb(false, "")
end, { buffer = win.buf_id, nowait = true })
end
---- not worried about perf, we will likely only ever have 1 maybe 2 windows
---- ever open at the same time
function M.clear_active_popups()
- while #M.active_windows > 0 do
- local window = M.active_windows[1]
+ for _, window in ipairs(M.active_windows) do
window_close(window)
end
+ M.active_windows = {}
end
return M
diff --git a/scratch/refresh.lua b/scratch/refresh.lua
index 8c34a9c..0777f65 100644
--- a/scratch/refresh.lua
+++ b/scratch/refresh.lua
@@ -2,10 +2,140 @@ local Window = require("99.window")
Window.clear_active_popups()
R("99")
-local function test()
- Window = require("99.window")
- Window.capture_input(function(input)
- print(input)
- end, {})
+-- Modern Neovim completion example using vim.lsp.completion and custom completions
+-- This is the newer API that replaced omnifunc
+
+-- Example 1: Using the built-in completion with a custom source
+local function setup_custom_completion()
+ -- Set up completion options
+ vim.opt.completeopt = { "menu", "menuone", "noselect" }
+
+ -- Create a custom completion function
+ -- This is called when user triggers completion (Ctrl-X Ctrl-U by default)
+ vim.api.nvim_buf_set_option(0, 'completefunc', 'v:lua.custom_complete')
end
-test()
+
+-- Custom completion function
+-- Returns completion items when called
+function _G.custom_complete(findstart, base)
+ if findstart == 1 then
+ -- First call: return the column where completion starts
+ local line = vim.api.nvim_get_current_line()
+ local col = vim.api.nvim_win_get_cursor(0)[2]
+
+ -- Find start of the word
+ local start = col
+ while start > 0 and line:sub(start, start):match("[%w_]") do
+ start = start - 1
+ end
+
+ return start
+ else
+ -- Second call: return list of completions
+ -- 'base' is the text to complete
+ local completions = {
+ { word = "example_item", menu = "Example", kind = "Function" },
+ { word = "example_variable", menu = "Example", kind = "Variable" },
+ { word = "example_constant", menu = "Example", kind = "Constant" },
+ }
+
+ -- Filter completions based on what user typed
+ local matches = {}
+ for _, item in ipairs(completions) do
+ if item.word:find("^" .. base) then
+ table.insert(matches, item)
+ end
+ end
+
+ return matches
+ end
+end
+
+-- Example 2: Using nvim-cmp style manual completion
+-- This is the modern approach used by popular plugins
+local function create_completion_source()
+ return {
+ items = {
+ { label = "Window.clear_active_popups", kind = vim.lsp.protocol.CompletionItemKind.Method },
+ { label = "Window.capture_input", kind = vim.lsp.protocol.CompletionItemKind.Method },
+ { label = "Window.create_popup", kind = vim.lsp.protocol.CompletionItemKind.Method },
+ { label = "custom_item", kind = vim.lsp.protocol.CompletionItemKind.Variable },
+ },
+
+ complete = function(self, ctx)
+ local line = ctx.line
+ local cursor = ctx.cursor
+
+ -- Return filtered items based on context
+ return vim.tbl_filter(function(item)
+ return item.label:find(ctx.query or "", 1, true) ~= nil
+ end, self.items)
+ end
+ }
+end
+
+-- Example 3: Set up omnifunc (older API, but still works)
+-- This is triggered with Ctrl-X Ctrl-O
+function _G.my_omnifunc(findstart, base)
+ if findstart == 1 then
+ local line = vim.api.nvim_get_current_line()
+ local col = vim.api.nvim_win_get_cursor(0)[2]
+ return vim.fn.match(line:sub(1, col), [[\k*$]])
+ else
+ -- Return completion matches
+ return {
+ "Window.clear_active_popups()",
+ "Window.capture_input(callback, opts)",
+ "Window.create_floating_window()",
+ { word = "advanced_item", abbr = "adv", menu = "Custom", info = "Detailed info here" },
+ }
+ end
+end
+
+-- Set it up for the current buffer
+vim.api.nvim_buf_set_option(0, 'omnifunc', 'v:lua.my_omnifunc')
+
+-- Usage:
+-- Ctrl-X Ctrl-O to trigger omnifunc completion
+-- Ctrl-X Ctrl-U to trigger completefunc completion
+
+-- Example: Auto-trigger completion when typing '@' in 99-prompt buffer
+
+-- Custom completion function that provides context-aware items
+function _G.prompt_at_complete(findstart, base)
+ if findstart == 1 then
+ -- Find the start position (right after the '@')
+ local line = vim.api.nvim_get_current_line()
+ local col = vim.api.nvim_win_get_cursor(0)[2]
+
+ -- Find the '@' symbol
+ local start = col
+ while start > 0 and line:sub(start, start) ~= '@' do
+ start = start - 1
+ end
+
+ return start
+ else
+ -- Return completion items based on what comes after '@'
+ local completions = {
+ { word = "@file", menu = "Reference a file", kind = "File" },
+ { word = "@buffer", menu = "Reference a buffer", kind = "Reference" },
+ { word = "@selection", menu = "Reference current selection", kind = "Reference" },
+ { word = "@codebase", menu = "Search entire codebase", kind = "Keyword" },
+ { word = "@web", menu = "Search the web", kind = "Keyword" },
+ }
+
+ -- Filter based on what user has typed
+ local matches = {}
+ for _, item in ipairs(completions) do
+ if item.word:find("^@" .. vim.pesc(base), 1) then
+ table.insert(matches, item)
+ end
+ end
+
+ return matches
+ end
+end
+
+setup_custom_completion()
+