use crate::texts::tr;
use crate::tools;
use serde_json::{json, Value};
pub const AUTO: &str = "default";
pub const DASHBOARD: &str = "https://cursor.com/dashboard?tab=usage";
pub fn full_id(model: &str) -> String {
if model == AUTO {
"default[]".into()
} else {
model.to_string()
}
}
pub fn effort_key(listed: &Value, model: &str) -> Option<String> {
let base = model.split('[').next().unwrap_or(model);
let m = listed["models"]
.as_array()?
.iter()
.find(|m| m["value"] == base)?;
let thought = m["configOptions"]
.as_array()?
.iter()
.find(|c| c["category"] == "thought_level")?;
let efforts = thought["options"].as_array().is_some_and(|o| !o.is_empty());
efforts
.then(|| thought["id"].as_str().map(str::to_string))
.flatten()
}
#[derive(Default)]
pub struct Todos {
list: Vec<Value>,
}
impl Todos {
pub fn set(&mut self, todos: &Value, merge: bool) -> Value {
if !merge {
self.list.clear();
}
for t in todos.as_array().into_iter().flatten() {
match self
.list
.iter_mut()
.find(|k| k["id"] == t["id"] && !t["id"].is_null())
{
Some(k) => {
for (key, v) in t.as_object().into_iter().flatten() {
k[key] = v.clone();
}
}
None => self.list.push(t.clone()),
}
}
Value::Array(
self.list
.iter()
.filter(|t| t["status"] != "cancelled")
.map(|t| {
let status = match t["status"].as_str() {
Some("in_progress") => "in_progress",
Some("completed") => "completed",
_ => "pending",
};
json!({ "content": t["content"], "status": status, "activeForm": t["content"] })
})
.collect(),
)
}
}
pub fn own_tool(u: &Value) -> bool {
matches!(
u["rawInput"]["_toolName"].as_str(),
Some("updateTodos" | "createPlan" | "askQuestion")
)
}
pub fn spawned(u: &Value) -> (Option<String>, Option<String>) {
(
u["subagentSessionId"].as_str().map(str::to_string),
u["_meta"]["cursor"]["toolCallId"]
.as_str()
.map(tools::call_id),
)
}
pub fn tool_of(u: &Value, cwd: &str) -> Option<(String, Value)> {
let raw = &u["rawInput"];
if let (Some(server), Some(tool)) =
(raw["providerIdentifier"].as_str(), raw["toolName"].as_str())
{
return Some((format!("mcp__{server}__{tool}"), raw["args"].clone()));
}
let title = u["title"].as_str().unwrap_or_default();
match raw["_toolName"].as_str() {
Some("task") => {
let mut input = json!({
"description": raw["description"].as_str().unwrap_or(title.trim_start_matches("Task: ")),
"prompt": raw["prompt"], "subagent_type": raw["subagentType"],
});
if let Some(o) = input.as_object_mut() {
o.retain(|_, v| !v.is_null());
}
Some(("Task".into(), input))
}
Some(own)
if !matches!(
u["kind"].as_str(),
Some("execute" | "edit" | "delete" | "read" | "search" | "fetch")
) =>
{
let mut input = raw.clone();
if let Some(o) = input.as_object_mut() {
o.remove("_toolName");
}
let _ = cwd;
Some((own.to_string(), input))
}
_ => None,
}
}
pub fn mcp_of(tc: &Value, name: &str) -> Option<(String, String)> {
if let Some(rest) = name.strip_prefix("mcp__") {
let (server, tool) = rest.split_once("__")?;
return Some((server.to_string(), tool.to_string()));
}
let raw = &tc["rawInput"];
Some((
raw["providerIdentifier"].as_str()?.to_string(),
raw["toolName"].as_str()?.to_string(),
))
}
pub fn mode_of(acp: &str, mode: &str) -> Option<String> {
match acp {
"plan" => Some("plan".into()),
_ if mode == "plan" => Some("default".into()),
_ => None,
}
}
pub fn questions(p: &Value) -> Value {
let header = p["title"].as_str().unwrap_or_default();
Value::Array(
p["questions"]
.as_array()
.into_iter()
.flatten()
.map(|q| {
let options: Vec<Value> = q["options"]
.as_array()
.into_iter()
.flatten()
.map(|o| json!({ "label": o["label"], "description": "" }))
.collect();
json!({
"question": q["prompt"], "header": header,
"multiSelect": q["allowMultiple"] == true, "options": options,
})
})
.collect(),
)
}
pub fn question_outcome(params: &Value, answers: &Value) -> Value {
let mut picked = Vec::new();
let mut written = Vec::new();
for q in params["questions"].as_array().into_iter().flatten() {
let text = q["prompt"].as_str().unwrap_or_default();
let answer = answers[text].as_str().unwrap_or_default().trim();
let options = q["options"].as_array().cloned().unwrap_or_default();
let id_of = |label: &str| {
options
.iter()
.find(|o| o["label"].as_str() == Some(label))
.and_then(|o| o["id"].as_str())
.map(str::to_string)
};
let ids: Option<Vec<String>> = if answer.is_empty() {
Some(Vec::new())
} else {
id_of(answer).map(|id| vec![id]).or_else(|| {
answer
.split(", ")
.map(id_of)
.collect::<Option<Vec<String>>>()
})
};
match ids {
Some(ids) => picked.push(json!({ "questionId": q["id"], "selectedOptionIds": ids })),
None => written.push(format!("{text} {answer}")),
}
}
if written.is_empty() {
json!({ "outcome": "answered", "answers": picked })
} else {
json!({ "outcome": "skipped", "reason": written.join("\n") })
}
}
pub fn plan_text(p: &Value) -> String {
[
p["name"].as_str().map(|n| format!("# {n}")),
p["overview"].as_str().map(str::to_string),
p["plan"].as_str().map(str::to_string),
]
.into_iter()
.flatten()
.filter(|t| !t.trim().is_empty() && t != "# ")
.collect::<Vec<_>>()
.join("\n\n")
}
pub fn plan_answer(
allow: bool,
always: bool,
message: Option<&str>,
) -> (Value, Option<&'static str>) {
match (allow, always) {
(true, true) => (json!({ "outcome": "accepted" }), Some("acceptEdits")),
(true, false) => (json!({ "outcome": "accepted" }), Some("default")),
_ => (json!({ "outcome": "rejected", "reason": message }), None),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refusal {
Upgrade,
Payment,
SignIn,
Unauthenticated,
}
pub fn refusal(text: &str) -> Option<Refusal> {
let last = text.trim().rsplit("\n\n").next().unwrap_or_default().trim();
match last {
"Upgrade your plan to continue" => Some(Refusal::Upgrade),
"Add a payment method to continue" => Some(Refusal::Payment),
"Please sign in to continue" => Some(Refusal::SignIn),
t if t.starts_with("Error: [unauthenticated]") => Some(Refusal::Unauthenticated),
_ => None,
}
}
pub fn at_limit(refusal: Refusal) -> bool {
matches!(refusal, Refusal::Upgrade | Refusal::Payment)
}
pub fn refusal_error(lang: &str, refusal: Refusal, model: &str, by_key: bool) -> String {
let url = DASHBOARD;
match refusal {
Refusal::Upgrade if model == AUTO || model.is_empty() => tr(
lang,
format!("Limite d’usage de ce compte Cursor atteinte (« Upgrade your plan to continue ») : l’usage inclus du mois est épuisé jusqu’à la remise à zéro de son offre. Son usage : {url}"),
format!("This Cursor account’s usage limit is reached (“Upgrade your plan to continue”): the month’s included usage is spent until its plan resets. Its usage: {url}"),
),
Refusal::Upgrade => tr(
lang,
format!("Cursor refuse le modèle {model} sur ce compte (« Upgrade your plan to continue ») : il n’est pas dans l’offre de ce compte Cursor, ou son usage inclus du mois est épuisé. Choisis Auto dans le menu Modèle ; son usage : {url}"),
format!("Cursor refuses the model {model} on this account (“Upgrade your plan to continue”): it isn’t in this Cursor account’s plan, or its included usage of the month is spent. Choose Auto in the Model menu; its usage: {url}"),
),
Refusal::Payment => tr(
lang,
format!("Limite d’usage de ce compte Cursor atteinte : la suite passe par l’usage à la demande, qui demande un moyen de paiement (« Add a payment method to continue »). Son usage : {url}"),
format!("This Cursor account’s usage limit is reached: what comes next is on-demand usage, which needs a payment method (“Add a payment method to continue”). Its usage: {url}"),
),
Refusal::SignIn | Refusal::Unauthenticated if by_key => tr(
lang,
"Cursor a refusé la clé d’API de ce compte : donne-la de nouveau dans Réglages › Comptes, « Changer la clé… ».".into(),
"Cursor refused this account’s API key: give it again in Settings › Accounts, “Change the key…”.".into(),
),
Refusal::SignIn | Refusal::Unauthenticated => tr(
lang,
"Cursor CLI n’est pas connecté sur cette machine (« Please sign in to continue ») : lance agent login dans un terminal.".into(),
"Cursor CLI isn’t signed in on this machine (“Please sign in to continue”): run agent login in a terminal.".into(),
),
}
}
pub fn write_not_done(lang: &str) -> String {
tr(
lang,
"Cursor n’a rien écrit : l’écriture a été refusée, par un hook (ceux de Claude Code tournent aussi sous Cursor) ou par Cursor lui-même, qui n’en donne pas la raison.".into(),
"Cursor wrote nothing: the write was refused, by a hook (those of Claude Code run under Cursor too) or by Cursor itself, which doesn’t give the reason.".into(),
)
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn the_effort_of_a_model_goes_by_the_option_its_list_gives_it() {
let listed = json!({ "models": [
{ "value": "claude-opus-5-5", "configOptions": [
{ "id": "effort", "category": "thought_level", "options": [{ "value": "low" }, { "value": "high" }] },
] },
{ "value": "default", "configOptions": [] },
] });
assert_eq!(
super::effort_key(&listed, "claude-opus-5-5").as_deref(),
Some("effort")
);
assert_eq!(
super::effort_key(&listed, "claude-opus-5-5[context=300k]").as_deref(),
Some("effort")
);
assert_eq!(super::effort_key(&listed, "default"), None);
assert_eq!(
super::effort_key(&serde_json::Value::Null, "claude-opus-5-5"),
None
);
}
}