Compare commits

..
4 Commits
5 changed files with 141 additions and 16 deletions

No files matched your search

+19
View File
@@ -3,6 +3,25 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00040] – 2026-11-15
### Fixed
- **Images you upload reach Claude Code**: its chats read the history without the image bytes, so the
files it was pointed to were empty. Images without data are no longer written as empty files.
## [v202611-00039] – 2026-11-15
### 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 ## [v202611-00038] – 2026-11-15
### Changed ### Changed
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00038" src="https://img.shields.io/badge/version-v202611--00038-d69e2e?style=flat-square"> <img alt="Version v202611-00040" src="https://img.shields.io/badge/version-v202611--00040-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+111 -5
View File
@@ -112,7 +112,47 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
// --- tool mapping ----------------------------------------------------------------------- // --- tool mapping -----------------------------------------------------------------------
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours. /// 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) { 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. // 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) { if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
return (ours.to_string(), input.clone()); 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). /// 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>) { 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); 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 info = CcTool { name: name.clone() };
let cfg = app.config(); let cfg = app.config();
let root = app.root_session_id(&session.id); let root = app.root_session_id(&session.id);
@@ -324,7 +361,12 @@ fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
let mut out = Vec::new(); let mut out = Vec::new();
for (i, p) in parts.iter().enumerate() { for (i, p) in parts.iter().enumerate() {
let Part::Image { image } = p else { continue }; let Part::Image { image } = p else { continue };
// An image without bytes (an unresolved reference) would become an empty file.
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue }; let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&image.data) else { continue };
if bytes.is_empty() {
tracing::warn!("claude code: image {i} of the message has no data – not saved");
continue;
}
let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png"); let ext = image.media_type.split('/').nth(1).unwrap_or("png").split('+').next().unwrap_or("png");
let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms())); let path = dir.join(format!("chat-{}-{i}.{ext}", now_ms()));
if std::fs::create_dir_all(&dir).is_ok() { if std::fs::create_dir_all(&dir).is_ok() {
@@ -342,7 +384,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let session = app.session(session_id)?; let session = app.session(session_id)?;
let owner = session.owner_id.clone(); let owner = session.owner_id.clone();
let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string(); let model_id = session.model.strip_prefix("claude-code/").unwrap_or("default").to_string();
let history = crate::runner::active_history(app.store.messages(session_id)?); // With the bytes of the images filled in (they are stored as references).
let history = crate::runner::active_history(app.store.messages_for_model(session_id)?);
let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else { let Some(last_user) = history.iter().rposition(|m| m.role == Role::User && m.parts.iter().any(|p| matches!(p, Part::Text { .. } | Part::Image { .. }))) else {
bail!("nothing to do: no user message"); bail!("nothing to do: no user message");
}; };
@@ -417,6 +460,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
let mut cur: Option<Cur> = None; let mut cur: Option<Cur> = None;
let mut calls: HashMap<String, String> = HashMap::new(); let mut calls: HashMap<String, String> = HashMap::new();
let mut diffs: 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 final_text = String::new();
let mut got_result = false; let mut got_result = false;
let mut failure: Option<String> = None; let mut failure: Option<String> = None;
@@ -498,7 +543,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 id = b["tool_use_id"].as_str().unwrap_or("").to_string();
let is_error = b["is_error"].as_bool().unwrap_or(false); 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 }); 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() { if !parts.is_empty() {
@@ -511,6 +556,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
"control_request" if ev["request"]["subtype"] == "can_use_tool" => { "control_request" if ev["request"]["subtype"] == "can_use_tool" => {
finalize(&app, session_id, &mut cur, &model_id); finalize(&app, session_id, &mut cur, &model_id);
let req = &ev["request"]; 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; let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
if allow { if allow {
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]); let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
@@ -893,6 +962,43 @@ pub async fn update() -> Result<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn uploaded_images_are_saved_with_their_bytes_only() {
let dir = std::env::temp_dir().join(format!("cc-uploads-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let parts = vec![
Part::Image { image: ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None } },
// A reference whose bytes were not filled in: must not become an empty file.
Part::Image { image: ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None } },
];
let saved = save_images(&dir.display().to_string(), &parts);
assert_eq!(saved.len(), 1);
assert_eq!(std::fs::read(&saved[0]).unwrap(), b"ABC");
std::fs::remove_dir_all(&dir).unwrap();
}
#[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] #[test]
fn tool_result_images_are_kept() { fn tool_result_images_are_kept() {
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]); let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
+2 -10
View File
@@ -600,9 +600,6 @@ button.session-group:hover {
background: color-mix(in srgb, var(--tag) 38%, var(--bg1)); background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
border-color: color-mix(in srgb, var(--tag) 70%, transparent); 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) { @media (max-width: 700px) {
.msg.user-msg { .msg.user-msg {
max-width: 94%; max-width: 94%;
@@ -4758,15 +4755,10 @@ span.chat-tab > button {
} }
} }
.chat-pane[data-color] { /* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
background: color-mix(in srgb, var(--tag) 16%, var(--bg0)); * the chat background itself stays neutral. */
}
.chat-pane[data-color] .composer-wrap {
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
}
.topbar[data-color] { .topbar[data-color] {
border-bottom: 3px solid var(--tag); border-bottom: 3px solid var(--tag);
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
} }
.session-item[data-color] { .session-item[data-color] {
box-shadow: inset 4px 0 0 var(--tag); box-shadow: inset 4px 0 0 var(--tag);
+8
View File
@@ -3,6 +3,14 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00040": {
en: ["Images you upload are now visible to Claude Code (they arrived empty before)"],
de: ["Hochgeladene Bilder sind für Claude Code jetzt sichtbar (sie kamen vorher leer an)"],
},
"202611-00039": {
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": { "202611-00038": {
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"], 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"], de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],