From ea8f4b82b87405dfdc746683cef078378c3d059e Mon Sep 17 00:00:00 2001 From: zeller-lorenz Date: Mon, 2 Nov 2026 16:08:12 +0000 Subject: [PATCH] =?UTF-8?q?Claude's=20effort=20goes=20up=20to=20xhigh=20an?= =?UTF-8?q?d=20max,=20chosen=20in=20Settings=20=E2=86=92=20AI=20and=20the?= =?UTF-8?q?=20CLI=20(R2.9)=20(#395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's effort goes up to xhigh and max. A model without a level gets the nearest one below it. The level is chosen in Settings → AI or with craft-anything-cli ai effort, and over SSH it reaches the other computer. R2.9. --- apps/craft-anything-cli/src/ai_cmd.rs | 41 ++++++++++++- crates/ai/src/anthropic.rs | 35 ++++++++++- crates/ai/src/cli_agents/mod.rs | 79 ++++++++++++++++++------- crates/ai/src/config.rs | 3 +- crates/ai/src/lib.rs | 24 ++++++++ crates/ai/src/model.rs | 28 ++++++--- crates/ai/src/tests.rs | 23 +++++++ crates/ui-egui/src/builders.rs | 9 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/settings_ui.rs | 16 +++++ crates/ui-egui/tests/headless_parity.rs | 1 + crates/ui-egui/tests/smoke.rs | 33 +++++++++++ docs/ROADMAP.md | 9 +++ docs/STATUS.md | 2 +- 15 files changed, 272 insertions(+), 35 deletions(-) diff --git a/apps/craft-anything-cli/src/ai_cmd.rs b/apps/craft-anything-cli/src/ai_cmd.rs index b5433c2b..4a22bb94 100644 --- a/apps/craft-anything-cli/src/ai_cmd.rs +++ b/apps/craft-anything-cli/src/ai_cmd.rs @@ -3,7 +3,7 @@ use crate::output::Format; use crate::{Cli, INTERRUPTED, emit, fail, make_engine, out}; use clap::{Args, Subcommand}; -use craft_anything_ai::{AgentEvent, AiHub, BackendConfig, BackendKind}; +use craft_anything_ai::{AgentEvent, AiHub, BackendConfig, BackendKind, Effort}; use craft_anything_evidence::ToolError; use serde_json::{Value, json}; use std::io::{Read as _, Write as _}; @@ -62,6 +62,9 @@ pub enum AiCommand { /// Set the model an AI uses (Claude Code: the model it's asked for); leave it out to list the /// ones it can use. Model { name: String, model: Option }, + /// Set how hard a Claude backend thinks (the Claude API or Claude Code): low, medium, high, + /// xhigh or max, or `default` for the usual level; leave it out to see the level and choices. + Effort { name: String, level: Option }, /// Send a tiny request and report latency. Test { name: String }, /// Choose the backend that builds (leave the name out to go back to automatic). @@ -269,6 +272,42 @@ pub fn ai(cli: &Cli, c: &AiCommand, f: Format) -> u8 { } Err(e) => fail(&e, f), }, + AiCommand::Effort { name, level: None } => { + let Some(b) = hub.backends().into_iter().find(|b| b.name == *name) else { + return fail(&ToolError::invalid(format!("No AI backend named {name:?}")).with_remediation("See `craft-anything-cli ai list`."), f); + }; + let options = hub.options(); + match craft_anything_ai::effort_in_use(&b, options.get(name)) { + Some(effort) => { + let levels: Vec<&str> = Effort::ALL.iter().map(|e| e.as_str()).collect(); + emit(&json!({"name": name, "effort": effort.as_str(), "levels": levels}), f); + 0 + } + None => fail( + &ToolError::invalid(format!("{name} doesn't take an effort level.")).with_remediation("Effort is for the Claude API and Claude Code."), + f, + ), + } + } + AiCommand::Effort { name, level: Some(level) } => { + let effort = match level.trim() { + "default" => None, + l => match Effort::parse(l) { + Some(e) => Some(e), + None => { + let e = ToolError::invalid(format!("{l:?} isn't an effort level.")).with_remediation("Use low, medium, high, xhigh, max or default."); + return fail(&e, f); + } + }, + }; + match hub.set_effort(name, effort) { + Ok(found) => { + emit(&json!({"name": name, "effort": effort.map(Effort::as_str), "found": found}), f); + if found { 0 } else { 1 } + } + Err(e) => fail(&e, f), + } + } AiCommand::Program { name, path } => match hub.set_command(name, path.as_deref()) { Ok(found) => { emit(&json!({"name": name, "program": path, "found": found}), f); diff --git a/crates/ai/src/anthropic.rs b/crates/ai/src/anthropic.rs index df09a9c3..9a6f5871 100644 --- a/crates/ai/src/anthropic.rs +++ b/crates/ai/src/anthropic.rs @@ -2,7 +2,7 @@ //! server-side fallbacks. use crate::http::{self, SseEvent}; -use crate::model::{Block, ChatModel, Delta, Msg, Speaker, Stop, StopReason, ToolDef, TurnRequest, TurnResult, Usage}; +use crate::model::{Block, ChatModel, Delta, Effort, Msg, Speaker, Stop, StopReason, ToolDef, TurnRequest, TurnResult, Usage}; use craft_anything_evidence::{ErrorCode, ToolError}; use serde_json::{Value, json}; @@ -10,6 +10,19 @@ pub const DEFAULT_BASE: &str = "https://api.anthropic.com"; pub const DEFAULT_MODEL: &str = "claude-opus-5-5"; /// Offered in the model dropdown when the live list can't be fetched. pub const MODELS: &[&str] = &["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"]; +/// Models that offer fewer effort levels than all five (Claude's effort guide), by model id +/// prefix; any other model is sent the level as chosen. +pub const EFFORT_LEVELS: &[(&str, &[Effort])] = &[ + ("claude-opus-4-6", &[Effort::Low, Effort::Medium, Effort::High, Effort::Max]), + ("claude-sonnet-4-6", &[Effort::Low, Effort::Medium, Effort::High, Effort::Max]), + ("claude-opus-4-5", &[Effort::Low, Effort::Medium, Effort::High]), +]; + +/// The effort level `model` is sent: `effort`, or the nearest level below it the model has. +pub fn effort_for(model: &str, effort: Effort) -> Effort { + let model = model.trim().trim_start_matches("anthropic/").trim_start_matches("anthropic."); + EFFORT_LEVELS.iter().find(|(prefix, _)| model.starts_with(prefix)).map_or(effort, |(_, levels)| effort.within(levels)) +} pub const API_VERSION: &str = "2023-06-01"; pub const FALLBACK_BETA: &str = "server-side-fallback-2026-07-01"; pub const MAX_TOKENS: u32 = 64_000; @@ -58,7 +71,7 @@ impl Anthropic { "system": [{"type": "text", "text": req.system, "cache_control": {"type": "ephemeral"}}], "messages": req.messages.iter().map(message_json).collect::>(), "thinking": {"type": "adaptive", "display": "summarized"}, - "output_config": {"effort": req.effort.as_str()}, + "output_config": {"effort": effort_for(&self.model, req.effort).as_str()}, }); if !req.tools.is_empty() { let n = req.tools.len(); @@ -354,6 +367,24 @@ mod tests { assert!(off.headers().iter().all(|(k, _)| *k != "anthropic-beta")); } + /// Every level goes to a model that has it; one that lacks it gets the nearest level below. + #[test] + fn effort_steps_down_to_a_level_the_model_has() { + for e in Effort::ALL { + assert_eq!(effort_for("claude-opus-5-5", e), e); + assert_eq!(Effort::parse(&e.as_str().to_uppercase()), Some(e)); + } + assert_eq!(effort_for("claude-sonnet-4-6", Effort::Xhigh), Effort::High); + assert_eq!(effort_for("anthropic/claude-opus-4-6", Effort::Max), Effort::Max); + assert_eq!(effort_for("claude-opus-4-5-20251101", Effort::Max), Effort::High); + assert_eq!(Effort::Low.within(&[Effort::Medium, Effort::High]), Effort::Medium, "none below: the lowest it has"); + assert_eq!(Effort::Max.within(&[]), Effort::Max); + assert_eq!(Effort::parse("extreme"), None); + let a = Anthropic::new(None, "k".into(), "claude-sonnet-4-6"); + let b = a.body(&TurnRequest { system: "s", messages: &[], tools: &[], effort: Effort::Xhigh, max_tokens: 10 }); + assert_eq!(b["output_config"]["effort"], "high"); + } + #[test] fn stream_state_builds_blocks() { let events = [ diff --git a/crates/ai/src/cli_agents/mod.rs b/crates/ai/src/cli_agents/mod.rs index 7187940b..98810126 100644 --- a/crates/ai/src/cli_agents/mod.rs +++ b/crates/ai/src/cli_agents/mod.rs @@ -437,6 +437,35 @@ impl CliConversation { } } +/// The environment an agent program runs with: Claude Code's limits raised for a long build, and +/// the effort it thinks with. Other agents ignore these names. +fn agent_env(builder: bool, effort: Option) -> Vec<(&'static str, String)> { + let (tool, shell) = if builder { (24 * 3600, 8 * 3600) } else { (40 * 60, 10 * 60) }; + let ms = |secs: u64| (secs * 1000).to_string(); + vec![ + ("MCP_TOOL_TIMEOUT", ms(tool)), + // Progress updates keep a long tool call alive; this also covers one that sends none. + ("CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT", "0".into()), + // A builder waits for every tool result (Ghidra above all) instead of carrying on without it. + ("CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS", if builder { "0" } else { "120000" }.into()), + ("BASH_DEFAULT_TIMEOUT_MS", ms(shell / 2)), + ("BASH_MAX_TIMEOUT_MS", ms(shell)), + // High effort for Claude Code unless chosen. An environment variable rather than + // `--effort`: it ranks highest, and an older Claude Code that doesn't know it ignores it. + ("CLAUDE_CODE_EFFORT_LEVEL", crate::prompt::choose_effort(effort).as_str().into()), + ] +} + +/// The command run on another computer over SSH. ssh passes on none of our environment, so it is +/// set there with `env`. +fn remote_command(program: &str, args: Vec, env: &[(&'static str, String)]) -> Vec { + let mut cmd = vec!["env".to_string()]; + cmd.extend(env.iter().map(|(k, v)| format!("{k}={v}"))); + cmd.push(program.to_string()); + cmd.extend(args); + cmd +} + fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, session: &Mutex>, verified: &Mutex>) { let name = spec.config.name.clone(); let agent = spec.config.model.clone(); @@ -483,12 +512,14 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses }; let args = build_args(adapter.as_ref(), &spec.options, text, &preamble, &mcp, if adapter.resumes() { resume.as_deref() } else { None }, access); let shown = FailureContext::new(&program, &args, text, remote.as_deref(), spec); + // A builder turn may take hours and sit silent for long stretches: Ghidra's first look at a big + // game, an engine build, or the person answering a question. Claude Code's own limits are + // raised to match, so nothing cuts a long clone short (its defaults stop a quiet tool call at + // 30 minutes and a background build at 10). + let (turn, quiet) = if builder { (24 * 3600, 8 * 3600) } else { (30 * 60, 15 * 60) }; + let env = agent_env(builder, spec.options.effort); let (prog, argv) = match &remote { - Some(host) => { - let mut remote_cmd = vec![program.clone()]; - remote_cmd.extend(args); - ("ssh".to_string(), crate::ssh::ssh_args(host, &remote_cmd)) - } + Some(host) => ("ssh".to_string(), crate::ssh::ssh_args(host, &remote_command(&program, args, &env))), None => (program.clone(), args), }; let _ = @@ -496,26 +527,14 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses let parser = Arc::new(Mutex::new(StreamParser::new(adapter.clone()))); let (p2, tx2, tx_err) = (parser.clone(), tx.clone(), tx.clone()); let stop2 = stop.clone(); - // A builder turn may take hours and sit silent for long stretches: Ghidra's first look at a big - // game, an engine build, or the person answering a question. Claude Code's own limits are - // raised to match, so nothing cuts a long clone short (its defaults stop a quiet tool call at - // 30 minutes and a background build at 10). - let (turn, quiet, tool, shell) = if builder { (24 * 3600, 8 * 3600, 24 * 3600, 8 * 3600) } else { (30 * 60, 15 * 60, 40 * 60, 10 * 60) }; - let ms = |secs: u64| (secs * 1000).to_string(); let mut proc = (if remote.is_none() { adapter.process(&prog) } else { Process::new(&prog) }) .args(&argv) .timeout(Duration::from_secs(spec.options.timeout_secs.unwrap_or(turn))) - .idle_timeout(Duration::from_secs(quiet)) - .env("MCP_TOOL_TIMEOUT", ms(tool)) - // Progress updates keep a long tool call alive; this also covers one that sends none. - .env("CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT", "0") - // A builder waits for every tool result (Ghidra above all) instead of carrying on without it. - .env("CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS", if builder { "0" } else { "120000" }) - .env("BASH_DEFAULT_TIMEOUT_MS", ms(shell / 2)) - .env("BASH_MAX_TIMEOUT_MS", ms(shell)) - // High effort for Claude Code. An environment variable rather than `--effort`: it ranks - // highest, and an older Claude Code that doesn't know it simply ignores it. - .env("CLAUDE_CODE_EFFORT_LEVEL", crate::prompt::choose_effort(spec.options.effort).as_str()) + .idle_timeout(Duration::from_secs(quiet)); + for (k, v) in &env { + proc = proc.env(k, v.as_str()); + } + let mut proc = proc .stdout_cap(4 * 1024 * 1024) .cancel_when(move || stop2.is_stopped()) .on_stdout_line(move |l| { @@ -744,6 +763,22 @@ impl Drop for CliConversation { #[cfg(test)] mod tests { use super::*; + + /// Over SSH our environment doesn't reach the other computer, so the chosen effort (and Claude + /// Code's raised limits) go in the remote command itself. + #[test] + fn an_agent_over_ssh_gets_its_effort_on_the_other_computer() { + let env = agent_env(true, Some(crate::model::Effort::Max)); + assert!(env.contains(&("CLAUDE_CODE_EFFORT_LEVEL", "max".to_string()))); + assert!(env.contains(&("CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS", "0".to_string()))); + let cmd = remote_command("claude", vec!["-p".into(), "hi".into()], &env); + assert_eq!(cmd.first().map(String::as_str), Some("env")); + assert!(cmd.contains(&"CLAUDE_CODE_EFFORT_LEVEL=max".to_string())); + assert_eq!(cmd[cmd.len() - 3..], ["claude", "-p", "hi"]); + let line = crate::ssh::ssh_args("box", &cmd).pop().unwrap_or_default(); + assert!(line.starts_with("env ") && line.contains("CLAUDE_CODE_EFFORT_LEVEL=max") && line.ends_with("claude -p hi"), "{line}"); + assert!(agent_env(false, None).contains(&("CLAUDE_CODE_EFFORT_LEVEL", "high".to_string())), "high unless chosen"); + } use crate::BackendKind; fn mcp() -> McpCommand { diff --git a/crates/ai/src/config.rs b/crates/ai/src/config.rs index 6c851f4d..e0d9065c 100644 --- a/crates/ai/src/config.rs +++ b/crates/ai/src/config.rs @@ -30,7 +30,8 @@ use std::collections::BTreeMap; #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct BackendOptions { - /// Thinking depth for investigations (`low`/`medium`/`high`); quick explanations use `low`. + /// Thinking depth (`low`/`medium`/`high`/`xhigh`/`max`) for the Claude API and Claude Code; + /// `high` unless set. A model without the level gets the nearest one below it. #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, /// Anthropic server-side fallbacks (default on). diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index 01b5827f..ab1c27cd 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -98,6 +98,17 @@ fn yes() -> bool { true } +/// The effort an AI thinks at, when it can be chosen (Settings → AI): the Claude API and Claude +/// Code, which take every level in [`Effort::ALL`]. `None` for the others, which don't use it. +pub fn effort_in_use(config: &BackendConfig, options: Option<&BackendOptions>) -> Option { + let claude = match config.kind { + BackendKind::Anthropic => true, + BackendKind::AgentCli => config.model == "claude-code", + _ => false, + }; + claude.then(|| prompt::choose_effort(options.and_then(|o| o.effort))) +} + /// The model an AI uses, when it can be chosen (Settings → AI, #123): an API's own, or the one /// Claude Code is asked for. `None` for an agent app that picks its own. pub fn model_in_use(config: &BackendConfig, options: Option<&BackendOptions>) -> Option { @@ -451,6 +462,19 @@ impl AiHub { Ok(true) } + /// How hard a backend thinks (`None`: the usual level again). Only the Claude API and Claude + /// Code take one ([`effort_in_use`]). Returns false when there was no such backend. + pub fn set_effort(&self, name: &str, effort: Option) -> Result { + let mut cfg = self.config_to_change()?; + let Some(b) = cfg.backends.iter_mut().find(|b| b.config.name == name) else { return Ok(false) }; + if effort.is_some() && effort_in_use(&b.config, Some(&b.options)).is_none() { + return Err(ToolError::invalid(format!("{name} doesn't take an effort level.")).with_remediation("Effort is for the Claude API and Claude Code.")); + } + b.options.effort = effort; + self.save_config(&cfg)?; + Ok(true) + } + /// The models a backend can be set to (#123): the API's own list, or Claude's models for Claude /// Code. Empty for an agent app that picks its own. Blocking (bounded); run it off the UI thread. pub fn model_choices(&self, name: &str) -> Result, ToolError> { diff --git a/crates/ai/src/model.rs b/crates/ai/src/model.rs index ee87bac5..fac497bf 100644 --- a/crates/ai/src/model.rs +++ b/crates/ai/src/model.rs @@ -81,32 +81,44 @@ pub struct ToolDef { pub schema: Value, } -/// How hard the model should think. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +/// How hard the model should think: Claude's effort levels, least to most (the order counts: a +/// level a model lacks steps down to the nearest one below). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Effort { Low, #[default] Medium, High, + /// Longer-horizon work than `high`. + Xhigh, + /// The deepest reasoning, with no limit on tokens spent. + Max, } impl Effort { + /// Every level, least to most. + pub const ALL: [Effort; 5] = [Effort::Low, Effort::Medium, Effort::High, Effort::Xhigh, Effort::Max]; + pub fn as_str(self) -> &'static str { match self { Effort::Low => "low", Effort::Medium => "medium", Effort::High => "high", + Effort::Xhigh => "xhigh", + Effort::Max => "max", } } pub fn parse(s: &str) -> Option { - match s { - "low" => Some(Effort::Low), - "medium" => Some(Effort::Medium), - "high" => Some(Effort::High), - _ => None, - } + let s = s.trim(); + Effort::ALL.into_iter().find(|e| e.as_str().eq_ignore_ascii_case(s)) + } + + /// This level on a model that offers only `levels`: the nearest one at or below it (as Claude + /// Code does), else the lowest it has. Unchanged when `levels` is empty. + pub fn within(self, levels: &[Effort]) -> Effort { + levels.iter().copied().filter(|l| *l <= self).max().or_else(|| levels.iter().copied().min()).unwrap_or(self) } } diff --git a/crates/ai/src/tests.rs b/crates/ai/src/tests.rs index a0b14318..25203fa8 100644 --- a/crates/ai/src/tests.rs +++ b/crates/ai/src/tests.rs @@ -235,6 +235,29 @@ fn an_ais_model_is_changed() { assert!(h.set_model("Box", " ").is_err()); } +/// Every Claude effort level can be chosen for the Claude API and Claude Code, and only for them. +#[test] +fn a_claude_ais_effort_is_changed() { + let (h, _d) = hub(); + let b = |name: &str, kind, model: &str| BackendConfig { name: name.into(), kind, model: model.into(), base_url: None, ssh_host: None, enabled: true }; + h.add_backend(b("Claude Code", BackendKind::AgentCli, "claude-code"), None).unwrap(); + h.add_backend(b("Codex", BackendKind::AgentCli, "codex"), None).unwrap(); + let effort = |h: &AiHub, name: &str| { + let config = h.backends().into_iter().find(|b| b.name == name).unwrap(); + crate::effort_in_use(&config, h.options().get(name)) + }; + assert_eq!(effort(&h, "Claude Code"), Some(Effort::High), "high until it's chosen"); + for level in Effort::ALL { + assert!(h.set_effort("Claude Code", Some(level)).unwrap()); + assert_eq!(effort(&h, "Claude Code"), Some(level)); + } + assert!(h.set_effort("Claude Code", None).unwrap()); + assert_eq!(effort(&h, "Claude Code"), Some(Effort::High), "back to the usual level"); + assert_eq!(effort(&h, "Codex"), None, "an agent that doesn't take Claude's levels"); + assert!(h.set_effort("Codex", Some(Effort::Max)).is_err()); + assert!(!h.set_effort("Nobody", Some(Effort::Low)).unwrap()); +} + /// #54, #98: a server on another computer is added by its address, with a model that can chat /// when none is named; one that doesn't answer with its models isn't added. #[test] diff --git a/crates/ui-egui/src/builders.rs b/crates/ui-egui/src/builders.rs index def1b404..c32d0939 100644 --- a/crates/ui-egui/src/builders.rs +++ b/crates/ui-egui/src/builders.rs @@ -103,6 +103,15 @@ impl CraftApp { }); } + /// How hard a Claude AI thinks from now on. + pub fn set_effort(&mut self, name: &str, effort: craft_anything_ai::Effort) { + let (hub, name) = (self.hub.clone(), name.to_string()); + self.jobs.spawn(tr("Change how hard an AI thinks"), false, move |_| { + hub.set_effort(&name, Some(effort)).map_err(|e| e.message)?; + Ok(crate::backends(&hub)) + }); + } + /// Connect the server in Settings → AI's address form. Its key goes to the keychain; the form /// keeps what was typed until it's added, so a mistake can be fixed. pub fn add_server(&mut self) { diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 222bcdac..9bb18f84 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -204,6 +204,7 @@ Capture menus, dialogs and tabs 采集菜单、对话框和标签页 Change 更改 Change a setting 更改设置 Change an AI's model 更改 AI 的模型 +Change how hard an AI thinks 更改 AI 的思考力度 Change it 更改 Change… 更改… Check 检查 @@ -364,6 +365,7 @@ Duplicate the project 创建项目副本 Each answer says how sure we are: Seen it, Worked it out or Best guess. 每个回答都会说明可信程度:已观察、已推导或推测。 Edit a project file 编辑项目文件 Edit them in place 原位编辑 +Effort: 思考力度: Electron and JavaScript Electron 与 JavaScript Electron fuses Electron 功能开关 Engines 引擎 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 17db5dd5..16dd4ea8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -204,6 +204,7 @@ Capture menus, dialogs and tabs 擷取選單、對話方塊和分頁 Change 變更 Change a setting 變更設定 Change an AI's model 變更 AI 的模型 +Change how hard an AI thinks 變更 AI 的思考力度 Change it 變更 Change… 變更… Check 檢查 @@ -364,6 +365,7 @@ Duplicate the project 建立專案副本 Each answer says how sure we are: Seen it, Worked it out or Best guess. 每個回答都會說明可信程度:已觀察、已推導或推測。 Edit a project file 編輯專案檔案 Edit them in place 直接編輯 +Effort: 思考力度: Electron and JavaScript Electron 與 JavaScript Electron fuses Electron 功能開關 Engines 引擎 diff --git a/crates/ui-egui/src/settings_ui.rs b/crates/ui-egui/src/settings_ui.rs index abe72c27..f49a8937 100644 --- a/crates/ui-egui/src/settings_ui.rs +++ b/crates/ui-egui/src/settings_ui.rs @@ -317,6 +317,7 @@ fn ai(app: &mut CraftApp, ui: &mut Ui) { let (mut pick, mut builds, mut extracts, mut toggle, mut remove) = (None, None, None, None, None); let (mut choose_program, mut usual_program) = (None, None); let (mut pick_model, mut chosen_model, mut close_picker) = (None, None, false); + let mut chosen_effort = None; // The AI whose Remove was pressed once: asked again before its key is deleted. let confirm_id = egui::Id::new("remove-ai"); let confirming: Option = ui.data(|d| d.get_temp(confirm_id)); @@ -351,6 +352,8 @@ fn ai(app: &mut CraftApp, ui: &mut Ui) { } }); } + // How hard it thinks: the Claude API and Claude Code take every Claude level. + let effort = craft_anything_ai::effort_in_use(b, app.backend_options.get(&b.name)); // Which model it uses, and the ones it can be set to (#123). if let Some(current) = model_in_use { ui.horizontal_wrapped(|ui| { @@ -382,6 +385,16 @@ fn ai(app: &mut CraftApp, ui: &mut Ui) { } } } + if let Some(current) = effort { + ui.label(RichText::new(tr("Effort:")).color(t.text_faint).size(theme::CAPTION)); + egui::ComboBox::from_id_salt(("effort", &b.name)).icon(widgets::combo_icon).selected_text(current.as_str()).show_ui(ui, |ui| { + for e in craft_anything_ai::Effort::ALL { + if ui.selectable_label(e == current, e.as_str()).clicked() && e != current { + chosen_effort = Some((b.name.clone(), e)); + } + } + }); + } }); } }); @@ -458,6 +471,9 @@ fn ai(app: &mut CraftApp, ui: &mut Ui) { if let Some((name, model)) = chosen_model { app.set_model(&name, &model); } + if let Some((name, effort)) = chosen_effort { + app.set_effort(&name, effort); + } if close_picker { app.model_picker = None; } diff --git a/crates/ui-egui/tests/headless_parity.rs b/crates/ui-egui/tests/headless_parity.rs index 6fc1214a..98d9edac 100644 --- a/crates/ui-egui/tests/headless_parity.rs +++ b/crates/ui-egui/tests/headless_parity.rs @@ -109,6 +109,7 @@ const ACTIONS: &[Action] = &[ a("add_server", "connect an AI server by its address", Cli("ai add --kind --base-url --key-env --key-stdin")), a("pick_model", "list the models an AI can use", Cli("ai model ")), a("set_model", "change the model an AI uses", Cli("ai model ")), + a("set_effort", "change how hard a Claude AI thinks", Cli("ai effort ")), a("choose_backend", "pick the AI brain", Cli("chat --backend")), a("set_role", "choose which AI builds or extracts", Cli("ai extractor ")), diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 6117c0d4..6724cf7c 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1024,6 +1024,39 @@ fn an_agent_apps_program_is_set_in_settings() { assert!(wait(&mut h, |a| program(a).is_none())); } +/// Claude Code's effort is chosen in Settings → AI, from every level Claude offers. +#[test] +fn a_claude_ais_effort_is_set_in_settings() { + use craft_anything_ai::{BackendConfig, BackendKind, Effort}; + let mut h = harness(|| { + let a = demo::app(demo::engine(Mode::Experienced, true)); + let cfg = BackendConfig { + name: "Claude Code".into(), + kind: BackendKind::AgentCli, + model: "claude-code".into(), + base_url: None, + ssh_host: None, + enabled: true, + }; + a.hub.add_backend(cfg, None).unwrap(); + a + }); + h.state_mut().refresh_backends(); + assert!(wait(&mut h, |a| a.backends.iter().any(|b| b.name == "Claude Code"))); + h.state_mut().state.settings_open = true; + h.state_mut().state.settings_tab = craft_anything_ui_egui::state::SettingsTab::Ai; + h.run_steps(2); + h.get_by_label("Effort:"); + assert!(h.state().backend_options.get("Claude Code").and_then(|o| o.effort).is_none(), "the usual level until one is chosen"); + // Chosen through the dropdown itself, which shows the level in use. + h.get_by_value("high").click(); + h.run(); + h.get_by_label("max").click(); + assert!(wait(&mut h, |a| a.backend_options.get("Claude Code").and_then(|o| o.effort) == Some(Effort::Max))); + h.run_steps(2); + h.get_by_value("max"); +} + #[test] fn settings_window_tabs_draw() { let mut h = harness(|| { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 288c00ed..ca6b6ad3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -880,6 +880,15 @@ Gaps found while using the app for real projects. - [ ] Two engine tests fail on a Mac, on `main` too: `real_binary_end_to_end_if_present` expects `/bin/ls` to be Linux, and `auto_mode_keeps_going_but_never_does_the_dangerous_things` is refused `rm -rf` in a temp folder under `/var/folders` +- [x] Every effort level Claude offers: `xhigh` and `max` next to low, medium and high, for the + Claude API and Claude Code, a level a model lacks stepping down to the nearest one it has, + and a way to choose it in Settings → AI and `ai effort` (only `config.toml` sets it now) +- [ ] Claude Code's own extras: its Ultracode switch (a setting, not an effort level; the + effort environment variable we use can't turn it on) and its model names beyond the list + (`opusplan`, `[1m]` variants) offered in Settings → AI; `ai model` already takes any name +- [ ] `a_helper_is_judged_by_whether_it_runs` (ai crate) sometimes fails when several crates' + tests run at once and passes alone: it writes a script and runs it straight away, which a + busy machine can refuse while another test's child still holds the file open ### R2.10 Pinned builds Owner: maintainers · Status: in progress diff --git a/docs/STATUS.md b/docs/STATUS.md index 62d6042f..10e4c985 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -25,7 +25,7 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi | Agents over ACP | Opt-in per backend (`acp = true`): Gemini CLI, Qwen Code, opencode, goose, Hermes and Maki in their ACP mode, one agent process per chat or build with our MCP server; Maki's ACP mode takes the backend's model (`cli_model`). In chat, permission requests become consent questions; in builds they go through the same approval policy as Claude Code's (inbox, Auto mode) | Protocol unit tests against scripted agent messages and an approval-policy test. Run live with Maki 0.7.0 only (2026-11-02, Linux, headless CLI): a static page built to Ready in 38 s, a follow-up continued the same session with `session/load`, and with Auto mode off its command and tool requests were refused in an unattended run. Turns report 0 tokens over ACP, and a continued session shows the earlier conversation again | | WASM plugins | With `--features wasm-plugins`: `[[plugins.wasm]]` components exporting `craft-anything:plugin/tools` add tools, and ones exporting `craft-anything:plugin/checks` add checks run after each kind's own (they get the build's file list as JSON) (added, listed, turned on or off and removed in Settings → Plugins or with `plugins_add`/`plugins_remove`/`plugins_list`); no imports, fresh instance per call, fuel and memory limits | Tests with components written in WAT (describe, call, checks, a looping call stopped, imports refused, end to end through the engine); `examples/wasm-plugin-wordcount` built from Rust and loaded | | Questions and approvals | A file inbox per project; `core_person_ask`, `core_action_approve` and MCP consent requests become cards in the app | Cross-handle round-trip tests, a full approval round trip through the engine | -| Desktop app | Simple mode: sidebar with New project and the project history (kept across restarts); New project either makes something or just looks inside an app or file, a website or a game's files; a Game files screen (a game's archives; pictures from Unreal paks, Unity .assets files and Unity bundles saved as PNG after the person confirms they may unpack the game; uncompressed textures and the block formats DXT1, DXT5 and BC4–BC7), make prompt with picture attachments and, for anything recreated, a Clean room / Deep choice (also in the title bar; in Clean room, Just look inside, Game files, opening a file and the workbench ask for Deep mode instead), project screen (steps with the time spent and the AI cost and tokens of every build, saved in the project's usage.json by the run (a coding agent's figure is shown as an estimate at API prices, not the bill), what the build is on now and its activity as it arrives (kept in `build-activity.log`), pinned builder checklist, feed, question and approval cards, a Continue card when a build was paused or stopped part-way (the app or computer closed; the project shows Interrupted) that carries on the builder's own session instead of starting over, and "It's done" beside Continue or Try again to keep the build as it is (`project_build_finish`), checks with screenshots and the requirements not met yet, the builder's open questions, preview, play, folder, follow-ups while it builds, which Claude Code takes at its next step; Up in the prompt box takes back the last note still waiting). Projects can start in a folder the person picks, whose files are either the app, edited in place, or material to start from (asked, or the answer remembered in Settings), and are listed from any number of folders (`project_folders`, `project_list`, `rebuild --project-folder`). Make says which AI builds ("Built by": automatic, or any AI that can build, by name); a pick stays with the project and builds its later changes. With nothing to build with, Start offers the coding agents found on the computer. Settings → AI turns an AI off or removes it. In the prompt box, Up at the very start and Down at the very end go through earlier prompts when no note is waiting. Questions about a build ("Just ask" under the prompt box, or a message starting with `/btw`, the first slash command; `rebuild_question_ask`) are answered in the conversation, where each folds away and is kept in the project, without a build or a round and changing nothing: Claude Code answers from a read-only copy of the builder's own session. Under each project's prompt box, "Builder's questions: Ask me / Decide yourself" chooses whether the builder's questions come to the person as cards or are decided by the builder, which says what it chose (`project_questions_set`; the default is in Settings and follows Auto mode until picked). Projects can be renamed, duplicated and deleted (right-click in the sidebar, or the project's page; a delete keeps the person's own files). A build can be pinned (Pin this build on its page, or `project_pin_*` from the CLI and MCP): a frozen copy in the project's pinned/ folder (the default) or a light one that shares dependencies and media with the project through hard links (each one's size shown first), opened from the sidebar's collapsible Pinned section (icons with a running dot when folded); a pin or Play runs once per folder unless another copy is asked for, and a start that quits at once says so with its last output. Typed drafts survive a restart. In a narrow window the sidebar folds to icons and the project's details move above its feed; messages float over the screen and errors stay until dismissed. Advanced mode: workbench with every panel wired to real tools, as a screen beside the projects (builds and their questions stay a click away) | UI unit and smoke tests (egui_kittest), offscreen snapshots of every screen | +| Desktop app | Simple mode: sidebar with New project and the project history (kept across restarts); New project either makes something or just looks inside an app or file, a website or a game's files; a Game files screen (a game's archives; pictures from Unreal paks, Unity .assets files and Unity bundles saved as PNG after the person confirms they may unpack the game; uncompressed textures and the block formats DXT1, DXT5 and BC4–BC7), make prompt with picture attachments and, for anything recreated, a Clean room / Deep choice (also in the title bar; in Clean room, Just look inside, Game files, opening a file and the workbench ask for Deep mode instead), project screen (steps with the time spent and the AI cost and tokens of every build, saved in the project's usage.json by the run (a coding agent's figure is shown as an estimate at API prices, not the bill), what the build is on now and its activity as it arrives (kept in `build-activity.log`), pinned builder checklist, feed, question and approval cards, a Continue card when a build was paused or stopped part-way (the app or computer closed; the project shows Interrupted) that carries on the builder's own session instead of starting over, and "It's done" beside Continue or Try again to keep the build as it is (`project_build_finish`), checks with screenshots and the requirements not met yet, the builder's open questions, preview, play, folder, follow-ups while it builds, which Claude Code takes at its next step; Up in the prompt box takes back the last note still waiting). Projects can start in a folder the person picks, whose files are either the app, edited in place, or material to start from (asked, or the answer remembered in Settings), and are listed from any number of folders (`project_folders`, `project_list`, `rebuild --project-folder`). Make says which AI builds ("Built by": automatic, or any AI that can build, by name); a pick stays with the project and builds its later changes. With nothing to build with, Start offers the coding agents found on the computer. Settings → AI turns an AI off or removes it, and sets how hard the Claude API or Claude Code thinks (every Claude level, low to max; also `ai effort` in the CLI). In the prompt box, Up at the very start and Down at the very end go through earlier prompts when no note is waiting. Questions about a build ("Just ask" under the prompt box, or a message starting with `/btw`, the first slash command; `rebuild_question_ask`) are answered in the conversation, where each folds away and is kept in the project, without a build or a round and changing nothing: Claude Code answers from a read-only copy of the builder's own session. Under each project's prompt box, "Builder's questions: Ask me / Decide yourself" chooses whether the builder's questions come to the person as cards or are decided by the builder, which says what it chose (`project_questions_set`; the default is in Settings and follows Auto mode until picked). Projects can be renamed, duplicated and deleted (right-click in the sidebar, or the project's page; a delete keeps the person's own files). A build can be pinned (Pin this build on its page, or `project_pin_*` from the CLI and MCP): a frozen copy in the project's pinned/ folder (the default) or a light one that shares dependencies and media with the project through hard links (each one's size shown first), opened from the sidebar's collapsible Pinned section (icons with a running dot when folded); a pin or Play runs once per folder unless another copy is asked for, and a start that quits at once says so with its last output. Typed drafts survive a restart. In a narrow window the sidebar folds to icons and the project's details move above its feed; messages float over the screen and errors stay until dismissed. Advanced mode: workbench with every panel wired to real tools, as a screen beside the projects (builds and their questions stay a click away) | UI unit and smoke tests (egui_kittest), offscreen snapshots of every screen | | CLI | Every tool, `--deep` or `--clean-room` for one run (never saved), `rebuild` (including `--continue-in`, and `--resume` to carry on a stopped build), `mcp --inbox`, `ai`, `setup`, `doctor`, `report` | CLI integration tests | | SQLite database files (`db_sqlite_show`) | A rowid alias column (`INTEGER PRIMARY KEY`, including the table-constraint form) shows the rowid; `DESC`, non-`INTEGER` and composite keys keep their stored values | Hand-built fixtures; generated files compared with the official SQLite library |