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()