diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce2bfc..fd7c55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Changed + +- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar + fill); it still marks the top bar line, the list entry, the badge and your own bubbles. + +### Fixed + +- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question + appears as a question card in the chat (single and multiple choice) and the answers go back to it. + ## [v202611-00038] – 2026-11-15 ### Changed diff --git a/server/src/claude_code.rs b/server/src/claude_code.rs index 52ab1e2..15efdb5 100644 --- a/server/src/claude_code.rs +++ b/server/src/claude_code.rs @@ -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 { + 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) -> 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::>().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, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option) { 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, session_id: &str, cancel: CancellationToken) -> let mut cur: Option = None; let mut calls: HashMap = HashMap::new(); let mut diffs: HashMap = HashMap::new(); + // Answers to AskUserQuestion calls, kept as the result's data for the question card. + let mut asked: HashMap = HashMap::new(); let mut final_text = String::new(); let mut got_result = false; let mut failure: Option = None; @@ -498,7 +537,7 @@ pub async fn run(app: Arc, 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, 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 { #[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"}}]); diff --git a/web/src/styles.css b/web/src/styles.css index f7fbb80..3cf9995 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -600,9 +600,6 @@ button.session-group:hover { background: color-mix(in srgb, var(--tag) 38%, var(--bg1)); border-color: color-mix(in srgb, var(--tag) 70%, transparent); } -.chat-pane[data-color] .msg.bubble.agent { - background: color-mix(in srgb, var(--tag) 7%, var(--bg1)); -} @media (max-width: 700px) { .msg.user-msg { max-width: 94%; @@ -4758,15 +4755,10 @@ span.chat-tab > button { } } -.chat-pane[data-color] { - background: color-mix(in srgb, var(--tag) 16%, var(--bg0)); -} -.chat-pane[data-color] .composer-wrap { - background: color-mix(in srgb, var(--tag) 16%, var(--bg0)); -} +/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles – + * the chat background itself stays neutral. */ .topbar[data-color] { border-bottom: 3px solid var(--tag); - background: color-mix(in srgb, var(--tag) 28%, var(--bg0)); } .session-item[data-color] { box-shadow: inset 4px 0 0 var(--tag); diff --git a/web/src/whatsnew.ts b/web/src/whatsnew.ts index 2de2241..8e2c87c 100644 --- a/web/src/whatsnew.ts +++ b/web/src/whatsnew.ts @@ -3,6 +3,10 @@ * under "unreleased"; scripts/release.sh renames the key to the release version. */ export const WHATS_NEW: Record = { + unreleased: { + en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"], + de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"], + }, "202611-00038": { en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"], de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],