diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 2fa2f83..7b3f464 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -4,6 +4,8 @@ --- @field on_complete fun(status: _99.Prompt.EndingState, res: string): nil --- @field on_start fun(): nil +local QFixHelpers = require("99.ops.qfix-helpers") + --- @param fn fun(...: any): nil --- @return fun(...: any): nil local function once(fn) @@ -231,23 +233,186 @@ end --- @class CursorAgentProvider : _99.Providers.BaseProvider local CursorAgentProvider = setmetatable({}, { __index = BaseProvider }) +local CURSOR_AGENT_QFIX_OPS = { + search = true, + vibe = true, +} + +--- @param context _99.Prompt +--- @return string +local function cursor_agent_workspace(_context) + return vim.fn.getcwd() +end + +--- @param relative string +--- @param ws string +--- @return string[] +local function cursor_agent_tmp_paths(relative, ws) + local seen, out = {}, {} + + local function add(path) + path = vim.fs.normalize(vim.fn.fnamemodify(path, ":p")) + if not seen[path] then + seen[path] = true + out[#out + 1] = path + end + end + + if relative:match("^[%a]:[/\\]") or relative:match("^/") then + add(relative) + return out + end + + local rel = relative:gsub("^%.[\\/]", "") + for _, base in ipairs({ ws, vim.fn.getcwd() }) do + add(vim.fs.joinpath(base, rel)) + end + return out +end + +--- @param paths string[] +--- @return string, string|nil +local function cursor_agent_read_first(paths) + for _, path in ipairs(paths) do + local ok, lines = pcall(vim.fn.readfile, path) + if ok and #lines > 0 then + return table.concat(lines, "\n"), path + end + end + return "", nil +end + +--- @param context _99.Prompt +--- @param ws string|nil +--- @return string +local function cursor_agent_prompt_file(context, ws) + ws = ws or cursor_agent_workspace(context) + for _, path in ipairs(cursor_agent_tmp_paths(context.tmp_file .. "-prompt", ws)) do + if vim.fn.filereadable(path) == 1 then + return path + end + end + return cursor_agent_tmp_paths(context.tmp_file .. "-prompt", ws)[1] +end + +--- @param context _99.Prompt +--- @param ws string|nil +--- @return string +local function cursor_agent_print_prompt(context, ws) + local prompt_file = cursor_agent_prompt_file(context, ws) + return string.format( + "Read and follow every instruction in @%s using your file tools, then complete the task exactly as specified in that file.", + prompt_file + ) +end + +--- @param text string +--- @return string +local function cursor_agent_text_outside_fences(text) + local out, in_fence = {}, false + for line in vim.gsplit(text or "", "\n", true) do + if line:match("^```") then + in_fence = not in_fence + elseif not in_fence then + out[#out + 1] = line + end + end + return table.concat(out, "\n") +end + +--- @param text string +--- @return string +function CursorAgentProvider.normalize_qfix_response(text) + local hits, seen = {}, {} + + local function add(path, lnum, col, line_count, notes) + path = vim.trim(path) + local key = string.format("%s:%d:%d", path, lnum, col) + if seen[key] then + return + end + seen[key] = true + hits[#hits + 1] = string.format( + "%s:%d:%d,%d,%s", + path, + lnum, + col, + line_count, + notes or "match" + ) + end + + text = text or "" + + -- Citation fences first — code lines inside a fence can false-match qfix parsing. + for sl, el, path in text:gmatch("```(%d+):(%d+):([^\r\n]+)") do + sl = tonumber(sl) + el = tonumber(el) + if sl and el and path then + add(path, sl, 1, math.max(1, el - sl + 1), "match") + end + end + if #hits > 0 then + return table.concat(hits, "\n") + end + + for line in vim.gsplit(cursor_agent_text_outside_fences(text), "\n", true) do + line = vim.trim(line) + if QFixHelpers.parse_line(line) and not seen[line] then + seen[line] = true + hits[#hits + 1] = line + end + end + return table.concat(hits, "\n") +end + --- @param query string --- @param context _99.Prompt --- @return string[] -function CursorAgentProvider._build_command(_, query, context) +function CursorAgentProvider._build_command(_, _query, context) -- TODO: trust is sort of a hack and should probably be removed in favor of having a -- trust flag from the setup call + local ws = cursor_agent_workspace(context) return { "cursor-agent", + "--workspace", + ws, "--trust", -- directories are always trusted and can be ran in "--force", -- allows for commands to run "--model", context.model, "--print", - query, + cursor_agent_print_prompt(context, ws), } end +--- @param context _99.Prompt +--- @return boolean, string +function CursorAgentProvider:_retrieve_response(context) + local ws = cursor_agent_workspace(context) + local tmp = cursor_agent_read_first(cursor_agent_tmp_paths(context.tmp_file, ws)) + + local function finish(text) + if CURSOR_AGENT_QFIX_OPS[context.operation] then + text = CursorAgentProvider.normalize_qfix_response(text) + end + if text:match("%S") then + return true, text + end + return false, "" + end + + if tmp:match("%S") then + return finish(tmp) + end + + local ok, text = BaseProvider._retrieve_response(self, context) + if ok and text:match("%S") then + return finish(text) + end + return false, "" +end + --- @return string function CursorAgentProvider._get_provider_name() return "CursorAgentProvider" diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index 803cd05..6e0dd8b 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -48,22 +48,77 @@ describe("providers", function() end) describe("CursorAgentProvider", function() - it("builds correct command with model", function() - local request = { model = "anthropic/claude-sonnet-4-5" } - local cmd = - Providers.CursorAgentProvider._build_command(nil, "test query", request) - eq({ - "cursor-agent", - "--model", - "anthropic/claude-sonnet-4-5", - "--print", - "test query", - }, cmd) + it("builds command with @ prompt file and workspace", function() + local request = { model = "sonnet-4.5", tmp_file = "tmp/99-1234" } + local cmd = Providers.CursorAgentProvider._build_command( + nil, + "ignored", + request + ) + local print_arg = cmd[#cmd] + eq("cursor-agent", cmd[1]) + eq("--workspace", cmd[2]) + eq(vim.fn.getcwd(), cmd[3]) + eq("--trust", cmd[4]) + eq("--force", cmd[5]) + eq("--model", cmd[6]) + eq("sonnet-4.5", cmd[7]) + eq("--print", cmd[8]) + assert(print_arg:match("@")) + assert(print_arg:match("99%-1234%-prompt")) + assert(not print_arg:match("")) + assert(not print_arg:match("\n")) end) it("has correct default model", function() eq("sonnet-4.5", Providers.CursorAgentProvider._get_default_model()) end) + + it("normalize_qfix_response converts cursor citations", function() + local text = table.concat({ + "```6:11:C:\\Dev\\test\\test.js", + "function main() {}", + "```", + }, "\n") + local out = Providers.CursorAgentProvider.normalize_qfix_response(text) + assert(out:match("C:\\Dev\\test\\test.js:6:1,6")) + end) + + it("normalize_qfix_response keeps existing qfix lines", function() + local line = "C:/project/file.lua:10:1,3,note" + eq(line, Providers.CursorAgentProvider.normalize_qfix_response(line)) + end) + + it("normalize_qfix_response ignores qfix-like strings inside citation fences", function() + local text = table.concat({ + "```6:11:C:\\Dev\\test\\test.js", + 'const x = err("file.js:5:3,4,msg")', + "```", + }, "\n") + local out = Providers.CursorAgentProvider.normalize_qfix_response(text) + assert(out:match("C:\\Dev\\test\\test.js:6:1,6")) + assert(not out:match("file%.js:5")) + end) + + it("normalize_qfix_response ignores qfix-like strings in plain code fences", function() + local text = table.concat({ + "```javascript", + 'const x = err("file.js:5:3,4,msg")', + "```", + }, "\n") + eq("", Providers.CursorAgentProvider.normalize_qfix_response(text)) + end) + + it("normalize_qfix_response keeps qfix lines outside code fences", function() + local line = "C:/project/file.lua:10:1,3,note" + local text = table.concat({ + line, + "```javascript", + 'const x = err("file.js:5:3,4,msg")', + "```", + }, "\n") + eq(line, Providers.CursorAgentProvider.normalize_qfix_response(text)) + end) end) describe("GeminiCLIProvider", function()