Compare commits

...
16 changed files with 666 additions and 35 deletions

No files matched your search

+43
View File
@@ -3,6 +3,49 @@
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
### Changed
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
## [v202611-00037] – 2026-11-15
### Added
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
not shared with any other chat, no project folder), you can delete it with the chat – with the
number of files, size and a warning for git repositories. The server checks again before deleting.
## [v202611-00036] – 2026-11-15
### Added
- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling,
diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge,
as `mcp__codecraft__<name>`. Tools the CLI has itself (read, write, edit, bash, grep …) are not
offered twice. Calls go through the usual approval cards and rules.
## [v202611-00035] – 2026-11-15 ## [v202611-00035] – 2026-11-15
### Fixed ### Fixed
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00035" src="https://img.shields.io/badge/version-v202611--00035-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">
+93 -3
View File
@@ -86,6 +86,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/search", get(search_in_session)) .route("/sessions/{id}/search", get(search_in_session))
.route("/sessions/{id}/media", get(session_media)) .route("/sessions/{id}/media", get(session_media))
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
.route("/sessions/{id}/folder", get(session_folder))
.route("/sessions/{id}/prompt", post(prompt)) .route("/sessions/{id}/prompt", post(prompt))
.route("/sessions/{id}/abort", post(abort)) .route("/sessions/{id}/abort", post(abort))
.route("/sessions/{id}/compact", post(compact)) .route("/sessions/{id}/compact", post(compact))
@@ -103,6 +104,7 @@ pub fn router(app: AppState) -> Router {
.route("/sessions/{id}/links", get(list_links).post(create_link)) .route("/sessions/{id}/links", get(list_links).post(create_link))
.route("/sessions/{id}/links/{token}", delete(delete_link)) .route("/sessions/{id}/links/{token}", delete(delete_link))
.route("/public/chat/{token}", get(public_chat)) .route("/public/chat/{token}", get(public_chat))
.route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle))
.route("/sessions/{id}/shares", get(get_shares).put(put_shares)) .route("/sessions/{id}/shares", get(get_shares).put(put_shares))
.route("/sessions/{id}/queue", get(get_queue)) .route("/sessions/{id}/queue", get(get_queue))
.route("/sessions/{id}/queue/flush", post(flush_queue)) .route("/sessions/{id}/queue/flush", post(flush_queue))
@@ -1055,8 +1057,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(json!({"ok": true}))) Ok(Json(json!({"ok": true})))
} }
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult { /// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
writable(&app, &user, &id)?; /// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
/// Returns the folder and the titles of chats that prevent it.
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
return (None, vec![]);
}
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
return (None, vec![]);
}
let mut blockers = Vec::new();
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
if other.starts_with(&dir) || dir.starts_with(&other) {
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
}
}
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
}
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
let session = writable(&app, &user, &id)?;
let (dir, blockers) = own_folder(&app, &session);
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
let dir = dir.clone();
move || {
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
let mut stack = vec![dir.clone()];
let mut truncated = false;
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
// Git's own files are not "the chat's files".
if e.file_name() == ".git" {
continue;
}
seen += 1;
if seen > 50_000 {
truncated = true;
stack.clear();
break;
}
let Ok(m) = e.metadata() else { continue };
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
stack.push(e.path());
} else if m.is_file() {
files += 1;
bytes += m.len();
}
}
}
(files, bytes, dir.join(".git").exists(), truncated)
}
})
.await
.unwrap_or((0, 0, false, false));
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
}
#[derive(Deserialize)]
struct DeleteQ {
/// Also delete the chat's own folder (only if it really is one – checked again here).
#[serde(default)]
delete_folder: bool,
}
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
let session = writable(&app, &user, &id)?;
// Decide about the folder while the chat and all the others still exist.
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
if q.delete_folder && folder.is_none() {
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
}
// Notify before deleting: visibility is checked against the stored session. // Notify before deleting: visibility is checked against the stored session.
app.emit(Event::SessionDeleted { session_id: id.clone() }); app.emit(Event::SessionDeleted { session_id: id.clone() });
runner::abort(&app, &id); runner::abort(&app, &id);
@@ -1070,7 +1147,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
tracing::warn!("image cleanup: {e:#}"); tracing::warn!("image cleanup: {e:#}");
} }
}); });
Ok(Json(json!({"ok": true}))) let mut folder_error = None;
let mut folder_deleted = false;
if let Some(dir) = folder {
let shown = dir.display().to_string();
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
Ok(Ok(())) => {
folder_deleted = true;
app.audit(Some(&user), "session.delete_folder", &shown, "");
}
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
}
}
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
} }
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes, /// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
+3
View File
@@ -189,6 +189,9 @@ fn is_public(path: &str) -> bool {
// Read-only chat links (the token is the credential). // Read-only chat links (the token is the credential).
|| path.starts_with("/public/chat/") || path.starts_with("/public/chat/")
|| path.starts_with("/api/public/chat/") || path.starts_with("/api/public/chat/")
// MCP bridge for the Claude Code CLI (per-run token).
|| path.starts_with("/mcp-bridge/")
|| path.starts_with("/api/mcp-bridge/")
} }
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response { pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
+127 -6
View File
@@ -112,7 +112,51 @@ 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.
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
return (ours.to_string(), input.clone());
}
let mut v = input.clone(); let mut v = input.clone();
let rename = |v: &mut Value, from: &str, to: &str| { let rename = |v: &mut Value, from: &str, to: &str| {
if let Some(x) = v.get(from).cloned() { if let Some(x) = v.get(from).cloned() {
@@ -149,6 +193,8 @@ impl ToolInfo for CcTool {
} }
fn read_only(&self) -> bool { fn read_only(&self) -> bool {
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list") matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
// One of our tools (MCP bridge): as it declares itself.
|| crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only())
} }
fn is_edit(&self) -> bool { fn is_edit(&self) -> bool {
matches!(self.name.as_str(), "write" | "edit") matches!(self.name.as_str(), "write" | "edit")
@@ -178,16 +224,16 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
match name { match name {
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()), "bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(), "write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
_ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()), _ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) {
Some(t) => t.title(input),
None => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_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);
@@ -315,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() {
@@ -333,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");
}; };
@@ -365,7 +417,13 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
if let Some(r) = &resume { if let Some(r) = &resume {
args.extend(["--resume".into(), r.clone()]); args.extend(["--resume".into(), r.clone()]);
} }
// CodeCraft's own tools for the CLI (MCP, per-run token).
let bridge = crate::mcp_bridge::register(session_id);
args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]);
let mut appended = session.instructions.trim().to_string(); let mut appended = session.instructions.trim().to_string();
if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) {
appended = format!("{appended}\n{note}").trim().to_string();
}
for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() { for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() {
appended = format!("{appended}\n{extra}").trim().to_string(); appended = format!("{appended}\n{extra}").trim().to_string();
} }
@@ -402,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;
@@ -483,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() {
@@ -496,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"]);
@@ -878,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"}}]);
+1
View File
@@ -24,6 +24,7 @@ mod lsp;
mod mentions; mod mentions;
mod metrics; mod metrics;
mod mcp; mod mcp;
mod mcp_bridge;
mod media; mod media;
mod mesh3d; mod mesh3d;
mod permission; mod permission;
+252
View File
@@ -0,0 +1,252 @@
//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers).
//!
//! The CLI only knows its own tools. For every run the server registers a secret token and
//! passes `--mcp-config` with `http://127.0.0.1:<port>/api/mcp-bridge/<token>`; the CLI then
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). The token is the
//! credential (the route is public) and binds the calls to the chat that started the run.
//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval
//! cards, rules and project approvals work as for the CLI's own tools.
use crate::app::App;
use crate::tools::{Tool, ToolCtx, ToolOutput};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use tokio_util::sync::CancellationToken;
pub const SERVER: &str = "codecraft";
/// Prefix the CLI gives our tools.
pub const PREFIX: &str = "mcp__codecraft__";
/// Our tools the CLI brings itself (same job): not offered twice.
const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"];
static TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Default::default);
/// A registered run; the token stops working when this is dropped.
pub struct Registration {
pub token: String,
}
impl Drop for Registration {
fn drop(&mut self) {
TOKENS.lock().unwrap().remove(&self.token);
}
}
pub fn register(session_id: &str) -> Registration {
let token = format!("{}{}", crate::types::new_id(), crate::types::new_id());
TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string());
Registration { token }
}
/// `--mcp-config` value for a run.
pub fn config_json(app: &App, token: &str) -> String {
let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(8787);
json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string()
}
/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images,
/// the server's MCP tools …) without the ones the CLI has itself.
pub fn tools_for(app: &Arc<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
let cwd = PathBuf::from(&session.cwd);
let root = app.root_for(session.owner_id.as_deref(), &cwd);
let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref());
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect()
}
/// Short note for the system prompt: what the extra tools are called.
pub fn prompt_section(tools: &[Arc<dyn Tool>]) -> Option<String> {
if tools.is_empty() {
return None;
}
let names: Vec<String> = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect();
Some(format!(
"\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \
Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \
If one is not in your tool list, load it with ToolSearch (query `select:<name>`).\n",
names.join(", ")
))
}
// --- protocol ----------------------------------------------------------------------------
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
}
fn call_result(res: anyhow::Result<ToolOutput>) -> Value {
match res {
Ok(out) => {
let mut content = vec![json!({"type": "text", "text": out.content})];
for img in out.images.iter().filter(|i| !i.data.is_empty()) {
content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type}));
}
json!({"content": content, "isError": out.is_error})
}
Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}),
}
}
fn answer(id: &Value, result: Value) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
}
fn failure(id: &Value, code: i64, message: &str) -> Response {
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response()
}
pub async fn handle(State(app): State<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> Response {
let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else {
return (StatusCode::NOT_FOUND, "unknown or finished run").into_response();
};
let id = req["id"].clone();
let method = req["method"].as_str().unwrap_or("");
// Notifications have no answer.
if req.get("id").is_none() || method.starts_with("notifications/") {
return StatusCode::ACCEPTED.into_response();
}
match method {
"initialize" => answer(
&id,
json!({
"protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"),
"capabilities": {"tools": {"listChanged": false}},
"serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")},
}),
),
"ping" => answer(&id, json!({})),
"tools/list" | "tools/call" => {
let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") };
let tools = tools_for(&app, &session);
if method == "tools/list" {
return answer(&id, tools_list(&tools));
}
let name = req["params"]["name"].as_str().unwrap_or("");
let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else {
return failure(&id, -32602, &format!("unknown tool '{name}'"));
};
let cwd = PathBuf::from(&session.cwd);
let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref());
let ctx = ToolCtx {
app: app.clone(),
session_id: session_id.clone(),
call_id: crate::types::new_id(),
cwd,
cancel: CancellationToken::new(),
agent: agent.name.clone(),
roots: app.user_roots(session.owner_id.as_deref()),
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft",
owner_id: None,
};
let input = req["params"]["arguments"].clone();
let input = if input.is_null() { json!({}) } else { input };
answer(&id, call_result(tool.run(input, &ctx).await))
}
_ => failure(&id, -32601, "method not found"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ImageData;
#[test]
fn registration_lives_as_long_as_the_run() {
let r = register("s1");
assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1"));
let t = r.token.clone();
drop(r);
assert!(!TOKENS.lock().unwrap().contains_key(&t));
}
#[test]
fn tool_results_carry_text_and_images() {
let out = ToolOutput {
content: "saved".into(),
images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }],
..Default::default()
};
let v = call_result(Ok(out));
assert_eq!(v["isError"], false);
let c = v["content"].as_array().unwrap();
assert_eq!(c.len(), 2); // the image without data is left out
assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png")));
assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true);
}
#[test]
fn cli_builtins_are_not_offered_twice() {
let all = crate::tools::builtin_tools();
let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect();
for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] {
assert!(ours.contains(&n), "{n} missing");
}
assert!(!ours.contains(&"read") && !ours.contains(&"bash"));
let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::<Vec<_>>());
assert!(list["tools"][0]["inputSchema"].is_object());
}
/// The whole protocol against a real App: initialize, list, call, unknown token.
#[tokio::test]
async fn protocol_roundtrip_with_a_real_app() {
let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id()));
std::fs::create_dir_all(dir.join("ws")).unwrap();
let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| {
if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) }
else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) }
else { l.to_string() }
}).collect::<Vec<_>>().join("\n");
let cfg = crate::config::Config::parse(&toml).unwrap();
let app = App::new(dir.join("config.toml"), cfg).await.unwrap();
let now = crate::types::now_ms();
let session = crate::types::Session {
id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(),
parent_id: None, owner_id: None, created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None,
pinned: false, archived: false, folder: None, instructions: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None,
};
let session = app.save_session(session).unwrap();
let reg = register(&session.id);
let rpc = |req: Value| {
let app = app.clone();
let token = reg.token.clone();
async move {
let resp = handle(State(app), Path(token), Json(req)).await;
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null))
}
};
let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert!(init["result"]["capabilities"]["tools"].is_object());
let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
assert_eq!(st, StatusCode::ACCEPTED);
let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await;
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}");
assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}");
// A real tool call: `remember` writes the project memory.
let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await;
assert_eq!(call["result"]["isError"], false, "{call}");
let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default();
assert!(mem.contains("bridge works"), "{mem}");
let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await;
assert!(bad["error"].is_object());
// A finished run's token is dead.
let token = reg.token.clone();
drop(reg);
let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let _ = std::fs::remove_dir_all(&dir);
}
}
+10
View File
@@ -409,6 +409,16 @@ impl Store {
Ok(()) Ok(())
} }
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
)?;
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
// Images go to the blob table (encoded before taking the lock). // Images go to the blob table (encoded before taking the lock).
let mut m = m.clone(); let mut m = m.clone();
+46
View File
@@ -0,0 +1,46 @@
import { del, get } from "./api";
import { choiceDialog, confirmDialog, toast } from "./dialogs";
import { t } from "./i18n";
import { formatSize } from "./components/FileFigure";
interface FolderInfo {
own: boolean;
path?: string;
files?: number;
bytes?: number;
git?: boolean;
truncated?: boolean;
}
/**
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
*/
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/folder`).catch(() => undefined);
let withFolder = false;
if (folder?.own) {
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
const choice = await choiceDialog({
title: t("chat.deleteTitle"),
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
choices: [
{ value: "chat", label: t("chat.deleteOnlyChat") },
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
],
});
if (!choice) return false;
withFolder = choice === "both";
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
return false;
}
try {
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
if (r?.folder_error) toast(r.folder_error);
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
return true;
} catch (e: any) {
toast(e.message);
return false;
}
}
+3 -4
View File
@@ -24,7 +24,8 @@ import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog"; import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel"; import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard"; import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { promptDialog, toast } from "../dialogs";
import { deleteChat } from "../chatActions";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions"; import { ProjectPermissions } from "./ProjectPermissions";
@@ -757,9 +758,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
}; };
const remove = async () => { const remove = async () => {
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return; if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
}; };
// Context usage of the last request relative to the model's window. // Context usage of the last request relative to the model's window.
+13 -3
View File
@@ -1,4 +1,4 @@
import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { deleteChat } from "../chatActions";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds"; import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
@@ -98,11 +98,21 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")} <Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
</button> </button>
)} )}
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}> <button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")} <Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button> </button>
{!s.shared && (
<button
role="menuitem"
className="danger"
onClick={async () => {
setMenu(false);
if ((await deleteChat(s)) && active) navigate("/new");
}}
>
<Icon name="trash" /> {t("chat.delete")}
</button>
)}
</div> </div>
)} )}
</div> </div>
+36 -8
View File
@@ -20,8 +20,15 @@ interface PromptOpts {
confirmLabel?: string; confirmLabel?: string;
} }
interface ChoiceOpts {
title?: string;
message: string;
choices: { value: string; label: string; danger?: boolean }[];
}
type Dialog = type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts) | ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts); | ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast { interface Toast {
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
}); });
} }
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> { export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts; const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
style={{ width: "100%" }} style={{ width: "100%" }}
/> />
)} )}
<div className="row dialog-actions"> {d.kind === "choice" ? (
<button type="button" onClick={() => close(d, cancelValue)}> <div className="row dialog-actions dialog-choices">
{t("common.cancel")} <button type="button" onClick={() => close(d, null)}>
</button> {t("common.cancel")}
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}> </button>
{d.confirmLabel ?? t("common.ok")} {d.choices.map((c, i) => (
</button> <button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
</div> {c.label}
</button>
))}
</div>
) : (
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
)}
</form> </form>
</div> </div>
); );
+7
View File
@@ -1161,4 +1161,11 @@ export const de: Messages = {
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren", "ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
"ssh.added": "Schlüssel hinzugefügt", "ssh.added": "Schlüssel hinzugefügt",
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.", "ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
"chat.deleteTitle": "Chat löschen",
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
"chat.deleteOnlyChat": "Nur Chat löschen",
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
"chat.folderFiles": "{n} Dateien",
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
"chat.folderDeleted": "Chat und Ordner gelöscht",
}; };
+7
View File
@@ -1160,6 +1160,13 @@ export const en = {
"ssh.copyFailed": "Could not copy – select the text manually", "ssh.copyFailed": "Could not copy – select the text manually",
"ssh.added": "Key added", "ssh.added": "Key added",
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.", "ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
"chat.deleteTitle": "Delete chat",
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
"chat.deleteOnlyChat": "Delete chat only",
"chat.deleteChatAndFolder": "Delete chat and folder",
"chat.folderFiles": "{n} files",
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
"chat.folderDeleted": "Chat and folder deleted",
}; };
export type Messages = Record<keyof typeof en, string>; export type Messages = Record<keyof typeof en, string>;
+4 -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);
@@ -5026,3 +5018,5 @@ td.fold-indent {
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } .ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
.ssh-key .row { align-items: center; flex-wrap: wrap; } .ssh-key .row { align-items: center; flex-wrap: wrap; }
.ssh-key input.mono { width: 100%; font-size: 12px; } .ssh-key input.mono { width: 100%; font-size: 12px; }
.dialog-message { overflow-wrap: anywhere; }
.dialog-choices { flex-wrap: wrap; }
+20
View File
@@ -3,6 +3,26 @@
* 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": {
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"],
},
"202611-00037": {
en: ["Deleting a chat now asks whether to delete its own folder too"],
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
},
"202611-00036": {
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
},
"202611-00035": { "202611-00035": {
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"], en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"], de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],