diff options
| author | ThePrimeAgain <theprimeagain@theprimeagain.com> | 2026-01-15 08:23:22 -0700 |
|---|---|---|
| committer | ThePrimeAgain <theprimeagain@theprimeagain.com> | 2026-01-15 08:23:22 -0700 |
| commit | 4e24d093d50177e299823703759a5e276d198170 (patch) | |
| tree | 5fa8506968f25bd7419256a5cb5ca4407cb85a0b | |
| parent | fc4ca85249de2f0311a62c6f17442996f8f79034 (diff) | |
| download | a4-4e24d093d50177e299823703759a5e276d198170.tar.xz a4-4e24d093d50177e299823703759a5e276d198170.zip | |
cmp: working through creating a cmp source
| -rw-r--r-- | lua/99/agents/helpers.lua | 97 | ||||
| -rw-r--r-- | lua/99/extensions/cmp.lua | 109 | ||||
| -rw-r--r-- | lua/99/extensions/init.lua | 5 | ||||
| -rw-r--r-- | lua/99/test/agent.helpers_spec.lua | 46 | ||||
| -rw-r--r-- | lua/99/window/init.lua | 90 | ||||
| -rw-r--r-- | scratch/refresh.lua | 142 |
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() + |
