Skip to content
Merged
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
569 changes: 569 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ rust-version = "1.85"
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ The editor experience stays the same.
## Status and compatibility

Pairagen is beta software. It has been developed and tested primarily with the
Codex CLI app-server backend. Generic CLI, stdio agent, and local model adapters
are available, but currently receive less real-world testing than Codex.
Codex CLI app-server backend. Persistent Claude CLI (stream-json), Ollama HTTP,
generic CLI, and stdio agent adapters are available, but currently receive less
real-world testing than Codex.

Requirements:

Expand All @@ -36,6 +37,9 @@ Implemented capabilities include:
- patch gate
- mock backend
- generic CLI backend
- persistent Claude CLI backend (one stream-json process per session)
- Ollama HTTP backend for local models (model stays loaded, JSON-forced output)
- structured agent denial (`deny` op) rendered as a distinct card
- deterministic token-budgeted project context with LSP hints and dependency ranking

## Installation
Expand Down Expand Up @@ -170,6 +174,21 @@ Cards stay anchored beside the source line and do not take focus. Use `<leader>p
to jump to a finding or the first line of an inline draft, and `<leader>pr` to
focus the current Pair card.

When the agent can only proceed from a different file — say the fix belongs in
a component class while a template is open — it does not fail or deny. It asks
mid-turn: Pair shows a permission prompt ("Pair agent wants to open …"), and on
approval the file opens and the same agent turn continues with the new buffer.
One request, no retry. Declining (or ignoring for two minutes) surfaces a deny
card with the location and an `[o] Open & retry` shortcut instead.

By default the first card is whatever fits the prompt best: a hypothesis, a
finding, or a clarifying choice when the prompt is ambiguous. Start the prompt
with `/{kind}` to demand a specific card instead — `/hypothesis`, `/finding`,
`/patch` (alias `/fix`), `/choice`, or `/summary`. For example
`/patch guard the payload here` skips discovery and drafts a patch directly.
Unknown words after `/` are treated as normal prompt text, so paths like
`/tmp/project` are safe.

The goal and accepted-step count stay visible on cards and editable drafts. After
accepting a patch, Pair shows a local receipt without calling the agent again.
`Next` explicitly continues the same goal and must return either one local patch
Expand Down
22 changes: 22 additions & 0 deletions doc/tags
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
:Pair pairagen.txt /*:Pair*
:PairAgent pairagen.txt /*:PairAgent*
:PairFix pairagen.txt /*:PairFix*
:PairFollow pairagen.txt /*:PairFollow*
:PairHide pairagen.txt /*:PairHide*
:PairLog pairagen.txt /*:PairLog*
:PairLogClear pairagen.txt /*:PairLogClear*
:PairModel pairagen.txt /*:PairModel*
:PairNext pairagen.txt /*:PairNext*
:PairOther pairagen.txt /*:PairOther*
:PairReply pairagen.txt /*:PairReply*
:PairReset pairagen.txt /*:PairReset*
:PairResume pairagen.txt /*:PairResume*
:PairStop pairagen.txt /*:PairStop*
:PairWhy pairagen.txt /*:PairWhy*
pairagen pairagen.txt /*pairagen*
pairagen-commands pairagen.txt /*pairagen-commands*
pairagen-contents pairagen.txt /*pairagen-contents*
pairagen-health pairagen.txt /*pairagen-health*
pairagen-privacy pairagen.txt /*pairagen-privacy*
pairagen-setup pairagen.txt /*pairagen-setup*
pairagen.txt pairagen.txt /*pairagen.txt*
19 changes: 19 additions & 0 deletions lua/pair/card.lua
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,16 @@ function M.lines(card)
M.add(lines, card.summary or card.title)
elseif card.kind == "error" then
M.add(lines, card.message or card.title)
elseif card.kind == "deny" then
table.insert(lines, "Agent could not proceed")
table.insert(lines, "")
M.add(lines, card.reason or card.title)
elseif card.kind == "choice" then
M.add(lines, card.question or card.title)
for index, option in ipairs(card.options or {}) do
table.insert(lines, "")
M.add(lines, string.format("%d. %s", index, option.label or option.id or ""))
end
end

local location = M.location(card)
Expand Down Expand Up @@ -247,6 +255,10 @@ function M.actions(card)
table.insert(parts, M.hint(config.values.keymaps.go_to, "Go to line"))
end

if card.kind == "deny" and type(card.location) == "table" then
table.insert(parts, M.hint("o", "Open & retry"))
end

for _, action in ipairs(actions) do
local name = type(action) == "table" and "apply_patch" or action
local label = labels[name]
Expand Down Expand Up @@ -293,6 +305,12 @@ function M.bind(buf, card)
require("pair").go_to()
end, { buffer = buf, nowait = true, silent = true })

if card.kind == "deny" and type(card.location) == "table" then
vim.keymap.set("n", "o", function()
require("pair").open_and_retry()
end, { buffer = buf, nowait = true, silent = true })
end

for _, action in ipairs(actions) do
local name = type(action) == "table" and "apply" or action
local label = labels[name]
Expand Down Expand Up @@ -327,6 +345,7 @@ function M.highlight(buf, lines, card)
or line:match("^At ") and "PairMuted"
or line:match("tokens$") and "PairMuted"
or card.kind == "error" and "DiagnosticError"
or card.kind == "deny" and line == "Agent could not proceed" and "DiagnosticError"
if group then
vim.api.nvim_buf_add_highlight(buf, -1, group, index - 1, 0, -1)
end
Expand Down
47 changes: 43 additions & 4 deletions lua/pair/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ M.values = {
args = {},
mode = "auto",
agent = "mock",
-- Speculative prefetch of the likely next card: "fix" requests the patch
-- in the background while you read a discovery card; "off" disables it.
prefetch = "fix",
},
distribution = {
repository = "DorianDevp/pairagen",
Expand Down Expand Up @@ -36,19 +39,24 @@ M.values = {
args = { "dev", "stdio-agent" },
},
claude = {
kind = "generic",
kind = "claude_app",
command = "claude",
args = {},
-- Discovery cards (hypothesis/finding/choice) run on a faster model
-- with a capped thinking budget; patch drafting keeps the main model
-- with adaptive thinking. Set either to nil to use the CLI default.
discovery_model = "haiku",
discovery_thinking = 1024,
},
aider = {
kind = "generic",
command = "aider",
args = {},
},
["local"] = {
kind = "generic",
command = "ollama",
args = { "run", "qwen2.5-coder:7b" },
kind = "ollama",
model = "qwen2.5-coder:7b",
host = "http://127.0.0.1:11434",
},
},
keymaps = {
Expand Down Expand Up @@ -285,7 +293,13 @@ end

function M.backend_env()
local _, agent = M.agent_config()
local env = M.agent_env(agent)
env.PAIR_PREFETCH = M.values.backend.prefetch or "fix"

return env
end

function M.agent_env(agent)
if agent.kind == "mock" then
return {
PAIR_BACKEND = "mock",
Expand Down Expand Up @@ -316,6 +330,31 @@ function M.backend_env()
}
end

if agent.kind == "claude_app" then
local args = vim.deepcopy(agent.args or {})

return {
PAIR_BACKEND = "claude_app",
PAIR_CLAUDE_COMMAND = agent.command,
PAIR_CLAUDE_ARGS = table.concat(args, " "),
PAIR_CLAUDE_ARGS_JSON = vim.json.encode(args),
PAIR_CLAUDE_MODEL = agent.model or "",
PAIR_CLAUDE_DISCOVERY_MODEL = agent.discovery_model or "",
PAIR_CLAUDE_DISCOVERY_THINKING = agent.discovery_thinking
and tostring(agent.discovery_thinking)
or "",
}
end

if agent.kind == "ollama" then
return {
PAIR_BACKEND = "ollama",
PAIR_OLLAMA_MODEL = agent.model or "",
PAIR_OLLAMA_HOST = agent.host or "",
PAIR_OLLAMA_KEEP_ALIVE = agent.keep_alive or "",
}
end

local args = M.agent_args(agent)

return {
Expand Down
43 changes: 43 additions & 0 deletions lua/pair/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ rpc.on("agent/progress", function(progress)
thinking.progress(progress)
end)

-- Mid-turn permission request: the agent can only continue once a different
-- file is open. On approval the same agent turn resumes with fresh context —
-- no card is shown and no extra request is spent.
rpc.on_request("editor/open_location", function(params, respond)
local location = params.location or {}
local file = location.file or "?"
local question = string.format(
"Pair agent wants to open %s:%s\n%s",
vim.fn.fnamemodify(file, ":~:."),
location.line or 1,
params.reason or ""
)

if vim.fn.confirm(question, "&Open\n&Deny", 1, "Question") == 1 and navigation.open_location(location) then
respond({ granted = true, context = context.session() })
else
respond({ granted = false })
end
end)

function M.setup(opts)
config.setup(opts)
require("pair.commands").setup()
Expand Down Expand Up @@ -81,6 +101,7 @@ function M.start(text, mode, source)
state.goal = message.result.goal or state.goal
state.token_usage = message.result.token_usage
state.turn_token_usage = message.result.turn_token_usage
state.backend_model = message.result.model or state.backend_model
state.context_report = message.result.context_report
log.event("context_optimization", message.result.context_report or {})
log.event("agent_attempts", message.result.attempts or {})
Expand Down Expand Up @@ -148,6 +169,7 @@ function M.action(action)

state.token_usage = message.result.token_usage
state.turn_token_usage = message.result.turn_token_usage
state.backend_model = message.result.model or state.backend_model
state.context_report = message.result.context_report
log.event("context_optimization", message.result.context_report or {})
log.event("agent_attempts", message.result.attempts or {})
Expand Down Expand Up @@ -212,6 +234,7 @@ function M.reply(text)

state.token_usage = message.result.token_usage
state.turn_token_usage = message.result.turn_token_usage
state.backend_model = message.result.model or state.backend_model
state.context_report = message.result.context_report
log.event("context_optimization", message.result.context_report or {})
log.event("agent_attempts", message.result.attempts or {})
Expand Down Expand Up @@ -246,6 +269,25 @@ function M.resume()
ui.notify("No Pair card to restore", vim.log.levels.WARN)
end

-- One-key continuation for a deny card that names a location: jump there, so
-- the next context capture sees that buffer, then retry the denied step.
function M.open_and_retry()
local active_card = state.card
if not (active_card and active_card.kind == "deny" and type(active_card.location) == "table") then
ui.notify("No location on this card", vim.log.levels.WARN)
return
end

if not navigation.open_location(active_card.location) then
ui.notify("Could not open " .. tostring(active_card.location.file), vim.log.levels.ERROR)
return
end

ui.close(state.card_win)
state.card_win = nil
M.action("retry")
end

function M.go_to()
if state.card and state.card.kind == "patch" and require("pair.diff").focus_change() then
return
Expand Down Expand Up @@ -313,6 +355,7 @@ function M.reset()
state.diff_first_row = nil
state.token_usage = nil
state.turn_token_usage = nil
state.backend_model = nil
state.context_report = nil
state.workspace_hints = nil
state.completion_notified_card = nil
Expand Down
11 changes: 10 additions & 1 deletion lua/pair/prompt.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ local M = {}
function M.open(mode)
local source = require("pair.context").capture()

-- Let the backend pay its startup cost (CLI boot, process spawn) while the
-- user is still typing the prompt.
require("pair.rpc").request("backend/warmup", {}, function() end)

M.open_for({
title = M.title("Prompt"),
footer = " Ctrl-s submit Esc normal q close ",
footer = " /kind forces card type Ctrl-s submit Esc normal q close ",
submit = function(text)
require("pair").start(text, mode, source)
end,
Expand All @@ -29,6 +33,11 @@ end
function M.title(kind)
local agent = config.agent()
local model = config.model()
if not model or model == "" then
-- No model configured: show what the backend reported it is actually
-- using, once a turn has completed.
model = state.backend_model
end
local active = model and model ~= "" and (agent .. " / " .. model) or (agent .. " / default")

return string.format(" Pair %s · %s ", kind, active)
Expand Down
43 changes: 43 additions & 0 deletions lua/pair/rpc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,24 @@ function M.on(method, callback)
M.notifications[method] = callback
end

-- Handlers for requests initiated by paird (method + id). The handler
-- receives (params, respond); it must call respond(result) exactly once.
function M.on_request(method, callback)
M.requests = M.requests or {}
M.requests[method] = callback
end

function M.respond(id, result)
local payload = vim.json.encode({
jsonrpc = "2.0",
id = id,
result = result,
})

log.event("rpc_server_response", { id = id })
vim.fn.chansend(M.job, payload .. "\n")
end

function M.on_data(data)
if not data or #data == 0 then
return
Expand Down Expand Up @@ -249,6 +267,31 @@ function M.handle(line)
return
end

if message.method and message.id ~= nil then
local handler = M.requests and M.requests[message.method]
local id = message.id

if not handler then
vim.fn.chansend(
M.job,
vim.json.encode({
jsonrpc = "2.0",
id = id,
error = { code = -32601, message = "unknown editor request " .. message.method },
}) .. "\n"
)
return
end

vim.schedule(function()
handler(message.params or {}, function(result)
M.respond(id, result)
end)
end)

return
end

if message.id then
local callback = M.pending[message.id]
M.pending[message.id] = nil
Expand Down
2 changes: 1 addition & 1 deletion lua/pair/version.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
return {
plugin = "0.1.0",
protocol = 5,
protocol = 6,
}
1 change: 1 addition & 0 deletions rust/crates/pair_backends/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ publish = false
anyhow.workspace = true
async-trait.workspace = true
pair_protocol = { path = "../pair_protocol" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
Loading