diff options
| author | codegirl007 <s.raide@gmail.com> | 2026-02-08 18:04:32 -0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-08 18:04:32 -0800 |
| commit | e4050fe6164765202814b353db43da75c1947af6 (patch) | |
| tree | 8e9fe8b195edcde1d3b37134be672655d169f5e1 /lua | |
| parent | 17be2bff90a22d8bafc102e3ca1730bb05026841 (diff) | |
| parent | 76acc1d15d8a2161d43b60ee8c5d49c1a5885c60 (diff) | |
| download | a4-e4050fe6164765202814b353db43da75c1947af6.tar.xz a4-e4050fe6164765202814b353db43da75c1947af6.zip | |
Merge branch 'master' into back-that-hash-up
Diffstat (limited to 'lua')
| -rw-r--r-- | lua/99/init.lua | 9 | ||||
| -rw-r--r-- | lua/99/ops/search.lua | 70 | ||||
| -rw-r--r-- | lua/99/ops/throbber.lua | 94 | ||||
| -rw-r--r-- | lua/99/prompt-settings.lua | 19 | ||||
| -rw-r--r-- | lua/99/providers.lua | 10 | ||||
| -rw-r--r-- | lua/99/request-context.lua | 7 | ||||
| -rw-r--r-- | lua/99/test/providers_spec.lua | 13 | ||||
| -rw-r--r-- | lua/99/test/throbber_spec.lua | 91 | ||||
| -rw-r--r-- | lua/99/window/init.lua | 9 |
9 files changed, 300 insertions, 22 deletions
diff --git a/lua/99/init.lua b/lua/99/init.lua index d48c26d..4f27543 100644 --- a/lua/99/init.lua +++ b/lua/99/init.lua @@ -256,10 +256,11 @@ local function set_selection_marks() end --- @param cb fun(context: _99.RequestContext, o: _99.ops.Opts?): nil +--- @param name string --- @param context _99.RequestContext --- @param opts _99.ops.Opts -local function capture_prompt(cb, context, opts) - Window.capture_input({ +local function capture_prompt(cb, name, context, opts) + Window.capture_input(name, { --- @param ok boolean --- @param response string cb = function(ok, response) @@ -331,7 +332,7 @@ function _99.search(opts) ops.search(context, o) return else - capture_prompt(ops.search, context, o) + capture_prompt(ops.search, "Search", context, o) end end @@ -368,7 +369,7 @@ function _99.visual(opts) if opts.additional_prompt then perform_range() else - capture_prompt(perform_range, context, opts) + capture_prompt(perform_range, "Visual", context, opts) end end diff --git a/lua/99/ops/search.lua b/lua/99/ops/search.lua index aa3d08a..a4017c0 100644 --- a/lua/99/ops/search.lua +++ b/lua/99/ops/search.lua @@ -1,7 +1,69 @@ +local Request = require("99.request") +local make_clean_up = require("99.ops.clean-up") +local Agents = require("99.extensions.agents") + +--- @param response string +local function create_search_locations(response) + local lines = vim.split(response, "\n") + print(vim.inspect(lines)) +end + --- @param context _99.RequestContext ---@param opts _99.ops.SearchOpts -return function(context, opts) - _ = context - _ = opts - error("search not implemented") +local function search(context, opts) + opts = opts or {} + local user_prompt = opts.additional_prompt + assert(user_prompt, "search requires a prompt to run, please provide prompt") + + local logger = context.logger:set_area("search") + local request = Request.new(context) + + logger:debug("search", "with opts", opts.additional_prompt) + + -- TODO: How to surface progress.. I was thinking about a status line plugin + -- local top_status = RequestStatus.new( + -- 250, + -- context._99.ai_stdout_rows or 1, + -- "Implementing", + -- top_mark + -- ) + local clean_up = make_clean_up(context, function() + request:cancel() + end) + + local full_prompt = context._99.prompts.prompts.semantic_search() + full_prompt = context._99.prompts.prompts.prompt(user_prompt, full_prompt) + local rules = Agents.find_rules(context._99.rules, user_prompt) + context:add_agent_rules(rules) + + local additional_rules = opts.additional_rules + if additional_rules then + context:add_agent_rules(additional_rules) + end + + request:add_prompt_content(full_prompt) + request:start({ + on_complete = function(status, response) + vim.schedule(clean_up) + if status == "cancelled" then + logger:debug("request cancelled for search") + elseif status == "failed" then + logger:error( + "request failed for search", + "error response", + response or "no response provided" + ) + elseif status == "success" then + create_search_locations(response) + end + end, + on_stdout = function(line) + --- TODO: i need to figure out how to surface this information + _ = line + end, + on_stderr = function(line) + logger:debug("visual_selection#on_stderr received", "line", line) + end, + }) end +return search diff --git a/lua/99/ops/throbber.lua b/lua/99/ops/throbber.lua new file mode 100644 index 0000000..b994fcd --- /dev/null +++ b/lua/99/ops/throbber.lua @@ -0,0 +1,94 @@ +local time = require("99.time") +local throb_icons = { + { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }, + { "◐", "◓", "◑", "◒" }, + { "⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷" }, + { "◰", "◳", "◲", "◱" }, + { "◜", "◠", "◝", "◞", "◡", "◟" }, +} + +--- @alias _99.Throbber.ThrobFN fun(perc: number): string +--- @alias _99.Throbber.EaseFN fun(perc: number): number + +--- @param ease_fn _99.Throbber.EaseFN +--- @return _99.Throbber.ThrobFN +local function create_throbber(ease_fn) + ease_fn = ease_fn or function(p) + return p + end + local icon_set = throb_icons[math.random(#throb_icons)] + return function(percent) + local eased = ease_fn(percent) + local index = math.floor(eased * #icon_set) + 1 + return icon_set[math.min(index, #icon_set)] + end +end + +local function ease_in_ease_out_cubic(percent) + if percent < 0.5 then + return 4 * percent * percent * percent + else + local f = (2 * percent) - 2 + return 1 - (f * f * f / 2) + end +end + +local throb_time = 1000 +local cooldown_time = 500 + +--- @class _99.Throbber +--- @field start_time number +--- @field section_time number +--- @field state "init" | "throbbing" | "cooldown" | "stopped" +--- @field throb_fn _99.Throbber.ThrobFN +--- @field cb fun(str: string): nil +local Throbber = {} +Throbber.__index = Throbber + +--- @param cb fun(str: string): nil +--- @return _99.Throbber +function Throbber.new(cb) + return setmetatable({ + state = "init", + start_time = 0, + section_time = 0, + cb = cb, + throb_fn = create_throbber(ease_in_ease_out_cubic), + }, Throbber) +end + +function Throbber:_run() + if self.state ~= "throbbing" and self.state ~= "cooldown" then + return + end + + local elapsed = time.now() - self.start_time + local percent = math.min(1, elapsed / self.section_time) + local icon = self.throb_fn(self.state == "throbbing" and percent or 1) + + if percent == 1 then + self.state = self.state == "cooldown" and "throbbing" or "cooldown" + self.start_time = time.now() + self.section_time = self.state == "cooldown" and cooldown_time or throb_time + end + + self.cb(icon) + vim.defer_fn(function() + self:_run() + end, 75) +end + +function Throbber:start() + self.start_time = time.now() + self.section_time = throb_time + self.state = "throbbing" + self:_run() +end + +function Throbber:stop() + self.state = "stopped" +end + +Throbber._icons = throb_icons + +return Throbber diff --git a/lua/99/prompt-settings.lua b/lua/99/prompt-settings.lua index 45f46dd..0bc564d 100644 --- a/lua/99/prompt-settings.lua +++ b/lua/99/prompt-settings.lua @@ -8,7 +8,7 @@ end --- @class _99.Prompts.SpecificOperations --- @field visual_selection fun(range: _99.Range): string --- @field semantic_search fun(): string ---- @field prompt fun(prompt: string, action: string, name: string): string +--- @field prompt fun(prompt: string, action: string, name?: string): string --- @field role fun(): string --- @field read_tmp fun(): string local prompts = { @@ -19,21 +19,26 @@ local prompts = { return [[ you are given a prompt and you must search through this project and return code that matches the description provided. <Rule>You must provide output without any commentary, just text locations</Rule> -<Rule>Text locations are in the format of: /path/to/file.ext:lnum:cnum,X +<Rule>Text locations are in the format of: /path/to/file.ext:lnum:cnum,X,NOTES lnum = starting line number 1 based cnum = starting column number 1 based X = how many lines should be highlighted +NOTES = A text description of why this highlight is important </Rule> +<Rule>NOTES cannot have new lines</Rule> +<Rule>You must adhere to the output format</Rule> +<Rule>Double check output format before writing it to the file</Rule> <Rule>Each location is separated by new lines</Rule> <Rule>Each path is specified in absolute pathing</Rule> +<Rule>You can provide notes you think are relevant per location</Rule> <Example> You have found 3 locations in files foo.js, bar.js, and baz.js. There are 2 locations in foo.js, 1 in bar.js and baz.js. <Output> -/path/to/project/src/foo.js:24:8,3 -/path/to/project/src/foo.js:71:12,7 -/path/to/project/src/bar.js:13:2,1 -/path/to/project/src/baz.js:1:1,52 +/path/to/project/src/foo.js:24:8,3,Some notes here about some stuff, it can contain commas +/path/to/project/src/foo.js:71:12,7,more notes, everything is great! +/path/to/project/src/bar.js:13:2,1,more notes again, this time specfically about bar and why bar is so important +/path/to/project/src/baz.js:1:1,52,Notes about why baz is very important to the results </Output> <Meaning> This means that the search results found @@ -53,7 +58,7 @@ ONLY provide requested changes by writing the change to TEMP_FILE end, --- @param prompt string --- @param action string - --- @param name string defaults to DIRECTIONS + --- @param name? string defaults to DIRECTIONS --- @return string prompt = function(prompt, action, name) name = name or "DIRECTIONS" diff --git a/lua/99/providers.lua b/lua/99/providers.lua index ab56738..7d991b2 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -141,7 +141,15 @@ local OpenCodeProvider = setmetatable({}, { __index = BaseProvider }) --- @param request _99.Request --- @return string[] function OpenCodeProvider._build_command(_, query, request) - return { "opencode", "run", "-m", request.context.model, query } + return { + "opencode", + "run", + "--agent", + "build", + "-m", + request.context.model, + query, + } end --- @return string diff --git a/lua/99/request-context.lua b/lua/99/request-context.lua index 358cdd2..6671cab 100644 --- a/lua/99/request-context.lua +++ b/lua/99/request-context.lua @@ -136,6 +136,13 @@ end --- @param prompt string function RequestContext:save_prompt(prompt) local prompt_file = self.tmp_file .. "-prompt" + + local dir = vim.fs.dirname(prompt_file) + + if dir and not vim.uv.fs_stat(dir) then + pcall(vim.uv.fs_mkdir, dir, 493) + end + local file = io.open(prompt_file, "w") if file then file:write(prompt) diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index 2c75030..809080b 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -8,10 +8,15 @@ describe("providers", function() local request = { context = { model = "anthropic/claude-sonnet-4-5" } } local cmd = Providers.OpenCodeProvider._build_command(nil, "test query", request) - eq( - { "opencode", "run", "-m", "anthropic/claude-sonnet-4-5", "test query" }, - cmd - ) + eq({ + "opencode", + "run", + "--agent", + "build", + "-m", + "anthropic/claude-sonnet-4-5", + "test query", + }, cmd) end) it("has correct default model", function() diff --git a/lua/99/test/throbber_spec.lua b/lua/99/test/throbber_spec.lua new file mode 100644 index 0000000..39425a5 --- /dev/null +++ b/lua/99/test/throbber_spec.lua @@ -0,0 +1,91 @@ +---- PURELY AI GENERATED FILE ----- +---- This could be crap test, i did about ~15 second code review, +---- looks mostly correct, a little weird, but close enough ---- +local Throbber = require("99.ops.throbber") +local eq = assert.are.same + +describe("Throbber", function() + it( + "cycles through throb, cooldown, and restart phases, then stops", + function() + local test_icons = { "a", "b", "c", "d", "e" } + local original_icons = Throbber._icons + Throbber._icons = { test_icons } + + local received = {} + local states = {} + --- @type _99.Throbber + local throbber + throbber = Throbber.new(function(icon) + table.insert(received, icon) + table.insert(states, throbber.state) + end) + throbber.throb_fn = function(percent) + local index = math.floor(percent * #test_icons) + 1 + return test_icons[math.min(index, #test_icons)] + end + + -- Start throbbing + throbber:start() + vim.wait(800) + + -- Verify we cycled through multiple icons (throb phase) + local seen_icons = {} + for _, icon in ipairs(received) do + seen_icons[icon] = true + end + assert.is_true( + seen_icons["a"] and seen_icons["b"] and seen_icons["c"], + "Expected to cycle through multiple icons during throb phase" + ) + + -- Wait for cooldown (should stay on first icon) + local icon_count_before_cooldown = #received + vim.wait(600) + + -- Verify cooldown: stays on first icon + local cooldown_icons = {} + for i = icon_count_before_cooldown + 1, #received do + table.insert(cooldown_icons, received[i]) + end + for _, icon in ipairs(cooldown_icons) do + eq("e", icon, "Expected cooldown to stay on last icon") + end + + -- Wait for second throb cycle to start + vim.wait(600) + + -- Verify we transitioned back to throbbing state + local seen_throbbing = false + local seen_cooldown = false + local seen_second_throb = false + for _, state in ipairs(states) do + if state == "throbbing" then + if not seen_throbbing then + seen_throbbing = true + elseif seen_cooldown then + seen_second_throb = true + end + elseif state == "cooldown" then + seen_cooldown = true + end + end + assert.is_true(seen_throbbing, "Expected to see throbbing state") + assert.is_true(seen_cooldown, "Expected to see cooldown state") + assert.is_true( + seen_second_throb, + "Expected to see second throbbing cycle" + ) + + -- Stop the throbber + throbber:stop() + local count_after_stop = #received + + -- Verify no more updates after stop + vim.wait(300) + eq(count_after_stop, #received, "Expected no more updates after stop") + + Throbber._icons = original_icons + end + ) +end) diff --git a/lua/99/window/init.lua b/lua/99/window/init.lua index 358b83b..6451ede 100644 --- a/lua/99/window/init.lua +++ b/lua/99/window/init.lua @@ -336,13 +336,14 @@ end --- @field on_load? fun(): nil --- @field rules _99.Agents.Rules +--- @param name string --- @param opts _99.window.CaptureInputOpts -function M.capture_input(opts) +function M.capture_input(name, opts) M.clear_active_popups() local config = create_centered_window() local win = create_floating_window(config, { - title = " 99 Prompt ", + title = string.format(" 99 %s ", name), border = "rounded", }) set_defaul_win_options(win, "99-prompt") @@ -420,4 +421,8 @@ function M.clear_active_popups() M.active_windows = {} end +function M.status_window() + M.clear_active_popups() +end + return M |
