Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 167 additions & 2 deletions lua/99/providers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment thread
sentry[bot] marked this conversation as resolved.

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"
Expand Down
77 changes: 66 additions & 11 deletions lua/99/test/providers_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"<Context>ignored</Context>",
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("<Context>"))
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()
Expand Down