Neutral chat background for colour tags; answer Claude Code's AskUserQuestion with the question card
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
77f9d4b0e0
commit
ead75e78e0
4 files changed
+107
-14
No files matched your search
@@ -112,7 +112,47 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
|
||||
// --- tool mapping -----------------------------------------------------------------------
|
||||
|
||||
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
|
||||
/// Claude Code's `AskUserQuestion` input → our questions (it writes `multiSelect`).
|
||||
fn cc_questions(input: &Value) -> Vec<crate::types::Question> {
|
||||
input["questions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|q| {
|
||||
let question = q["question"].as_str()?.to_string();
|
||||
let options = q["options"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| Some(crate::types::QuestionOption { label: o["label"].as_str()?.to_string(), description: o["description"].as_str().unwrap_or("").to_string() }))
|
||||
.collect();
|
||||
Some(crate::types::Question { question, header: q["header"].as_str().unwrap_or("").to_string(), options, multi_select: q["multiSelect"].as_bool().or(q["multi_select"].as_bool()).unwrap_or(false) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Our answers (question → text or list) as the CLI expects them: question → text, lists joined by ", ".
|
||||
fn cc_answers(answers: &serde_json::Map<String, Value>) -> Value {
|
||||
Value::Object(
|
||||
answers
|
||||
.iter()
|
||||
.map(|(q, a)| {
|
||||
let text = match a {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(l) => l.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(q.clone(), Value::String(text))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn map_tool(name: &str, input: &Value) -> (String, Value) {
|
||||
// Questions to the user show up as our question card / ask_user call.
|
||||
if name == "AskUserQuestion" {
|
||||
return ("ask_user".to_string(), json!({"questions": cc_questions(input)}));
|
||||
}
|
||||
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
|
||||
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
|
||||
return (ours.to_string(), input.clone());
|
||||
@@ -194,9 +234,6 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
|
||||
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
|
||||
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
|
||||
let (name, input) = map_tool(claude_name, claude_input);
|
||||
if claude_name == "AskUserQuestion" {
|
||||
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
|
||||
}
|
||||
let info = CcTool { name: name.clone() };
|
||||
let cfg = app.config();
|
||||
let root = app.root_session_id(&session.id);
|
||||
@@ -417,6 +454,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
let mut cur: Option<Cur> = None;
|
||||
let mut calls: HashMap<String, String> = HashMap::new();
|
||||
let mut diffs: HashMap<String, String> = HashMap::new();
|
||||
// Answers to AskUserQuestion calls, kept as the result's data for the question card.
|
||||
let mut asked: HashMap<String, Value> = HashMap::new();
|
||||
let mut final_text = String::new();
|
||||
let mut got_result = false;
|
||||
let mut failure: Option<String> = None;
|
||||
@@ -498,7 +537,7 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
}
|
||||
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
||||
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
||||
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
|
||||
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})).or_else(|| asked.remove(&id).map(|a| json!({"answers": a}))) };
|
||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
@@ -511,6 +550,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
|
||||
finalize(&app, session_id, &mut cur, &model_id);
|
||||
let req = &ev["request"];
|
||||
// A question for the user: shown as a question card; the answers go back to the CLI.
|
||||
if req["tool_name"] == "AskUserQuestion" {
|
||||
let questions = cc_questions(&req["input"]);
|
||||
let response = if questions.is_empty() {
|
||||
json!({"behavior": "deny", "message": "No usable question was given."})
|
||||
} else {
|
||||
match app.ask_user(session_id, "agent", questions, cancel.clone()).await {
|
||||
Ok(Some(answers)) => {
|
||||
let mut input = req["input"].clone();
|
||||
input["answers"] = cc_answers(&answers);
|
||||
if let Some(id) = req["tool_use_id"].as_str() {
|
||||
asked.insert(id.to_string(), Value::Object(answers));
|
||||
}
|
||||
json!({"behavior": "allow", "updatedInput": input})
|
||||
}
|
||||
_ => json!({"behavior": "deny", "message": "The user dismissed the questions without answering."}),
|
||||
}
|
||||
};
|
||||
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
|
||||
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
|
||||
if allow {
|
||||
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
|
||||
@@ -893,6 +956,28 @@ pub async fn update() -> Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn questions_roundtrip_with_the_cli_format() {
|
||||
let input = json!({"questions": [
|
||||
{"question": "Which database?", "header": "DB", "multiSelect": false, "options": [{"label": "SQLite", "description": "file"}, {"label": "Postgres"}]},
|
||||
{"question": "Which extras?", "header": "Extras", "multiSelect": true, "options": [{"label": "Auth"}, {"label": "Docs"}]},
|
||||
{"nonsense": true}
|
||||
]});
|
||||
let qs = cc_questions(&input);
|
||||
assert_eq!(qs.len(), 2);
|
||||
assert_eq!((qs[0].question.as_str(), qs[0].options.len(), qs[0].multi_select), ("Which database?", 2, false));
|
||||
assert!(qs[1].multi_select);
|
||||
// The tool card shows it as our ask_user.
|
||||
let (name, shown) = map_tool("AskUserQuestion", &input);
|
||||
assert_eq!(name, "ask_user");
|
||||
assert_eq!(shown["questions"][1]["multi_select"], true);
|
||||
// Answers: text stays, a multiple choice is joined.
|
||||
let answers = json!({"Which database?": "SQLite", "Which extras?": ["Auth", "Docs"]});
|
||||
let out = cc_answers(answers.as_object().unwrap());
|
||||
assert_eq!(out["Which database?"], "SQLite");
|
||||
assert_eq!(out["Which extras?"], "Auth, Docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_images_are_kept() {
|
||||
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
|
||||
|
||||
Reference in new issue
Block a user