From 49689833399ef2f845f74611193a625b1ba60b09 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:51:58 +0800 Subject: [PATCH 01/25] fix(tools): apply strict mode per schema (#3062) * fix: apply strict tool mode per schema * fix: preserve optional strict schema fields --- crates/tui/src/client/responses.rs | 69 +++++- crates/tui/src/tools/schema_sanitize.rs | 316 +++++++++++++++++++----- 2 files changed, 328 insertions(+), 57 deletions(-) diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 93150086..c3e3e2c7 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -597,11 +597,16 @@ fn convert_messages_to_responses_input(request: &MessageRequest) -> Vec { /// Convert a CodeWhale tool definition to a Responses API function tool. fn tool_to_responses_function(tool: &Tool) -> Value { let mut parameters = tool.input_schema.clone(); - schema_sanitize::sanitize_for_responses(&mut parameters); + let constraint_note = schema_sanitize::sanitize_for_responses(&mut parameters); + let description = match constraint_note { + Some(note) if tool.description.trim().is_empty() => note, + Some(note) => format!("{}\n\n{}", tool.description.trim(), note), + None => tool.description.clone(), + }; json!({ "type": "function", "name": tool.name, - "description": tool.description, + "description": description, "parameters": parameters, "strict": false, }) @@ -750,6 +755,66 @@ mod tests { assert!(parameters.get("not").is_none()); assert!(parameters["properties"].get("patch").is_some()); assert!(parameters["properties"].get("changes").is_some()); + assert_eq!( + payload["description"], + "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch`." + ); assert!(tool.input_schema.get("oneOf").is_some()); } + + #[test] + fn responses_function_tool_trims_description_before_constraint_note() { + let tool = Tool { + tool_type: None, + name: "apply_patch".to_string(), + description: "Apply patch\n".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "patch": {"type": "string"}, + "changes": {"type": "array"} + }, + "oneOf": [ + {"required": ["patch"]}, + {"required": ["changes"]} + ] + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + + let payload = tool_to_responses_function(&tool); + + assert_eq!( + payload["description"], + "Apply patch\n\nExactly one of these parameter groups must be provided: `changes` | `patch`." + ); + } + + #[test] + fn responses_function_tool_leaves_description_unchanged_without_constraint_note() { + let tool = Tool { + tool_type: None, + name: "lookup".to_string(), + description: "Lookup".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + } + }), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + }; + + let payload = tool_to_responses_function(&tool); + + assert_eq!(payload["description"], "Lookup"); + } } diff --git a/crates/tui/src/tools/schema_sanitize.rs b/crates/tui/src/tools/schema_sanitize.rs index a55bb969..fc39a02c 100644 --- a/crates/tui/src/tools/schema_sanitize.rs +++ b/crates/tui/src/tools/schema_sanitize.rs @@ -41,28 +41,21 @@ pub fn sanitize(schema: &mut Value) { /// Prepare a complete active tool set for DeepSeek strict function-calling. /// -/// Returns `false` and leaves the tools in non-strict mode when any root schema -/// uses conditional alternatives (`anyOf`, `oneOf`, or `allOf`). DeepSeek's -/// strict object rules make every property required, so forcing strict mode on -/// root-alternative tools such as `apply_patch` or `finance` would either 400 or -/// change their semantics. In that case callers should keep the normal -/// best-effort schema and may still use `tool_choice = "required"`. +/// Each tool is evaluated independently: compatible schemas are sanitized and +/// marked strict, while incompatible schemas remain unchanged and non-strict. +/// Returns `true` only when every tool in the set can use strict mode. pub fn prepare_tools_for_strict_mode(tools: &mut [Tool]) -> bool { - if tools - .iter() - .any(|tool| !strict_schema_supported(&tool.input_schema)) - { - for tool in tools { - tool.strict = None; - } - return false; - } - + let mut all_strict = true; for tool in tools { - sanitize_for_strict(&mut tool.input_schema); - tool.strict = Some(true); + if strict_schema_supported(&tool.input_schema) { + sanitize_for_strict(&mut tool.input_schema); + tool.strict = Some(true); + } else { + tool.strict = None; + all_strict = false; + } } - true + all_strict } /// Sanitize a schema for DeepSeek strict function-calling. @@ -82,7 +75,14 @@ pub fn sanitize_for_strict(schema: &mut Value) { /// schema permissive rather than changing tool semantics: merge any root /// alternative properties we can see, then remove the root-only composition /// keywords while preserving nested schemas. -pub fn sanitize_for_responses(schema: &mut Value) { +/// +/// Returns a short description note when root composition constraints with +/// meaningful `required` groups are dropped. +pub fn sanitize_for_responses(schema: &mut Value) -> Option { + let constraint_note = schema + .as_object() + .and_then(root_composition_constraint_note); + sanitize(schema); if !schema.is_object() { @@ -90,7 +90,7 @@ pub fn sanitize_for_responses(schema: &mut Value) { } let Some(obj) = schema.as_object_mut() else { - return; + return constraint_note; }; merge_root_composition_properties(obj); @@ -102,6 +102,7 @@ pub fn sanitize_for_responses(schema: &mut Value) { obj.remove("not"); ensure_properties_object(obj); prune_dangling_required(schema); + constraint_note } fn strict_schema_supported(schema: &Value) -> bool { @@ -230,13 +231,23 @@ fn enforce_strict_subset(schema: &mut Value) { if let Some(obj) = schema.as_object_mut() { strip_unsupported_strict_keywords(obj); if is_object_schema(obj) { - let mut property_names: Vec = ensure_properties_object(obj) - .keys() - .cloned() - .map(Value::String) - .collect(); - property_names.sort_by(|a, b| a.as_str().cmp(&b.as_str())); - obj.insert("required".into(), Value::Array(property_names)); + let originally_required = required_names(obj); + let properties = ensure_properties_object(obj); + let mut property_names: Vec = properties.keys().cloned().collect(); + property_names.sort(); + for property_name in &property_names { + if !originally_required + .iter() + .any(|required| required == property_name) + && let Some(property_schema) = properties.get_mut(property_name) + { + mark_nullable(property_schema); + } + } + obj.insert( + "required".into(), + Value::Array(property_names.into_iter().map(Value::String).collect()), + ); obj.insert("additionalProperties".into(), Value::Bool(false)); } @@ -279,6 +290,25 @@ fn ensure_properties_object(obj: &mut Map) -> &mut Map) -> Vec { + obj.get("required") + .and_then(Value::as_array) + .map(|required| { + required + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn mark_nullable(schema: &mut Value) { + if let Some(obj) = schema.as_object_mut() { + obj.insert("nullable".into(), Value::Bool(true)); + } +} + fn merge_root_composition_properties(obj: &mut Map) { let mut merged = Map::new(); for key in ["oneOf", "anyOf", "allOf"] { @@ -305,11 +335,64 @@ fn merge_root_composition_properties(obj: &mut Map) { } } +fn root_composition_constraint_note(obj: &Map) -> Option { + for (key, prefix) in [ + ("oneOf", "Exactly one"), + ("anyOf", "At least one"), + ("allOf", "All"), + ] { + let Some(items) = obj.get(key).and_then(Value::as_array) else { + continue; + }; + let mut groups: Vec = items.iter().filter_map(required_group_label).collect(); + groups.sort(); + groups.dedup(); + if groups.len() >= 2 { + return Some(format!( + "{prefix} of these parameter groups must be provided: {}.", + groups.join(" | ") + )); + } + } + None +} + +fn required_group_label(item: &Value) -> Option { + let mut names: Vec = item + .get("required")? + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(|name| format!("`{name}`")) + .collect(); + if names.is_empty() { + None + } else { + names.sort(); + names.dedup(); + Some(names.join(" + ")) + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn test_tool(name: &str, input_schema: Value) -> Tool { + Tool { + tool_type: None, + name: name.to_string(), + description: name.to_string(), + input_schema, + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: None, + cache_control: None, + } + } + #[test] fn collapses_nullable_anyof() { let mut schema = json!({ @@ -509,6 +592,53 @@ mod tests { assert_eq!(schema["additionalProperties"], false); assert_eq!(schema["required"], json!(["count", "name"])); + assert_eq!(schema["properties"]["count"]["nullable"], true); + assert!(schema["properties"]["name"].get("nullable").is_none()); + } + + #[test] + fn strict_sanitize_preserves_optional_properties_as_nullable() { + let mut schema = json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + "start_line": {"type": "integer"}, + "max_lines": {"type": "integer"}, + "options": { + "type": "object", + "properties": { + "encoding": {"type": "string"}, + "trim": {"type": "boolean"} + }, + "required": ["encoding"] + } + }, + "required": ["path", "options"] + }); + + sanitize_for_strict(&mut schema); + + assert_eq!( + schema["required"], + json!(["max_lines", "options", "path", "start_line"]) + ); + assert!(schema["properties"]["path"].get("nullable").is_none()); + assert!(schema["properties"]["options"].get("nullable").is_none()); + assert_eq!(schema["properties"]["start_line"]["nullable"], true); + assert_eq!(schema["properties"]["max_lines"]["nullable"], true); + assert_eq!( + schema["properties"]["options"]["required"], + json!(["encoding", "trim"]) + ); + assert!( + schema["properties"]["options"]["properties"]["encoding"] + .get("nullable") + .is_none() + ); + assert_eq!( + schema["properties"]["options"]["properties"]["trim"]["nullable"], + true + ); } #[test] @@ -577,32 +707,60 @@ mod tests { } #[test] - fn strict_mode_rejects_root_composition_for_whole_tool_set() { - let mut tools = vec![Tool { - tool_type: None, - name: "either".to_string(), - description: "Either input shape".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "a": {"type": "string"}, - "b": {"type": "string"} - }, - "anyOf": [ - {"required": ["a"]}, - {"required": ["b"]} - ] - }), - allowed_callers: None, - defer_loading: None, - input_examples: None, - strict: Some(true), - cache_control: None, - }]; + fn strict_mode_applies_per_tool_in_mixed_catalog() { + let mut tools = vec![ + test_tool( + "lookup", + json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + }, + "required": [] + }), + ), + test_tool( + "either", + json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"} + }, + "anyOf": [ + {"required": ["a"]}, + {"required": ["b"]} + ] + }), + ), + test_tool( + "nested", + json!({ + "type": "object", + "properties": { + "value": { + "oneOf": [ + {"type": "string"}, + {"type": "integer"} + ] + } + } + }), + ), + ]; assert!(!prepare_tools_for_strict_mode(&mut tools)); - assert_eq!(tools[0].strict, None); - assert!(tools[0].input_schema.get("anyOf").is_some()); + assert_eq!(tools[0].strict, Some(true)); + assert_eq!(tools[0].input_schema["required"], json!(["query"])); + assert_eq!(tools[0].input_schema["additionalProperties"], false); + assert_eq!(tools[1].strict, None); + assert!(tools[1].input_schema.get("anyOf").is_some()); + assert_eq!(tools[2].strict, None); + assert!( + tools[2].input_schema["properties"]["value"] + .get("oneOf") + .is_some() + ); } #[test] @@ -684,7 +842,7 @@ mod tests { ] }); - sanitize_for_responses(&mut schema); + let note = sanitize_for_responses(&mut schema); assert_eq!(schema["type"], "object"); assert!(schema.get("oneOf").is_none()); @@ -694,6 +852,10 @@ mod tests { assert!(schema.get("not").is_none()); assert!(schema["properties"].get("patch").is_some()); assert!(schema["properties"].get("changes").is_some()); + assert_eq!( + note.as_deref(), + Some("Exactly one of these parameter groups must be provided: `changes` | `patch`.") + ); } #[test] @@ -717,13 +879,17 @@ mod tests { ] }); - sanitize_for_responses(&mut schema); + let note = sanitize_for_responses(&mut schema); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); assert!(schema["properties"].get("path").is_some()); assert!(schema["properties"].get("url").is_some()); assert!(schema.get("required").is_none()); + assert_eq!( + note.as_deref(), + Some("At least one of these parameter groups must be provided: `path` | `url`.") + ); } #[test] @@ -740,11 +906,51 @@ mod tests { } }); - sanitize_for_responses(&mut schema); + let note = sanitize_for_responses(&mut schema); assert_eq!(schema["type"], "object"); assert!(schema.get("anyOf").is_none()); assert!(schema["properties"]["value"].get("anyOf").is_some()); + assert_eq!(note, None); + } + + #[test] + fn responses_sanitize_plain_object_has_no_constraint_note() { + let mut schema = json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + } + }); + + let note = sanitize_for_responses(&mut schema); + + assert_eq!(schema["type"], "object"); + assert_eq!(note, None); + } + + #[test] + fn responses_constraint_note_is_sorted_and_deduped() { + let mut schema = json!({ + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"}, + "c": {"type": "string"} + }, + "oneOf": [ + {"required": ["b", "a", "a"]}, + {"required": ["c"]}, + {"required": ["a", "b"]} + ] + }); + + let note = sanitize_for_responses(&mut schema); + + assert_eq!( + note.as_deref(), + Some("Exactly one of these parameter groups must be provided: `a` + `b` | `c`.") + ); } } From 1ac32df6278a9befd59c9be7711b371d64fa652f Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:52:02 +0800 Subject: [PATCH 02/25] feat(tui): dispatch hotbar slots from number keys (#3056) Wire hotbar key dispatch into the TUI event loop. Bare 1-8 now fires the matching hotbar slot only when the composer is empty. Alt+1 through Alt+8 fires the matching slot even when the composer has text. Modal and overlay views keep ownership of those keys, and empty slots remain a safe no-op. --- crates/tui/src/tui/ui.rs | 128 +++++++++++++++++++++++++++------ crates/tui/src/tui/ui/tests.rs | 62 ++++++++++++++++ 2 files changed, 168 insertions(+), 22 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 1c0061fd..f5691056 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -82,6 +82,7 @@ use crate::tui::footer_ui::{ friendly_subagent_progress, is_noisy_subagent_progress, one_line_summary, render_footer, }; use crate::tui::format_helpers; +use crate::tui::hotbar::actions::HotbarDispatch; use crate::tui::key_shortcuts; use crate::tui::live_transcript::LiveTranscriptOverlay; use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager}; @@ -3421,6 +3422,32 @@ async fn run_event_loop( continue; } + if let Some(slot) = hotbar_slot_from_key(app, &key) { + if let Some(dispatch) = dispatch_hotbar_slot(app, config, slot)? { + match dispatch { + HotbarDispatch::Handled => { + app.needs_redraw = true; + } + HotbarDispatch::AppAction(action) => { + if apply_command_result( + terminal, + app, + &mut engine_handle, + &task_manager, + config, + &mut web_config_session, + commands::CommandResult::action(action), + ) + .await? + { + return Ok(()); + } + } + } + } + continue; + } + // File-tree navigation: delegated to key_actions module. if key_actions::handle_file_tree_key(app, &key) { continue; @@ -3571,34 +3598,34 @@ async fn run_event_loop( toggle_live_transcript_overlay(app); continue; } - KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Work); - app.status_message = Some("Sidebar focus: work".to_string()); - } else { - apply_mode_update(app, &engine_handle, AppMode::Plan).await; - } + KeyCode::Char('1') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.set_sidebar_focus(SidebarFocus::Work); + app.status_message = Some("Sidebar focus: work".to_string()); continue; } - KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Tasks); - app.status_message = Some("Sidebar focus: tasks".to_string()); - } else { - apply_mode_update(app, &engine_handle, AppMode::Agent).await; - } + KeyCode::Char('2') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.set_sidebar_focus(SidebarFocus::Tasks); + app.status_message = Some("Sidebar focus: tasks".to_string()); continue; } - KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - app.set_sidebar_focus(SidebarFocus::Agents); - app.status_message = Some("Sidebar focus: agents".to_string()); - } else { - apply_mode_update(app, &engine_handle, AppMode::Yolo).await; - } + KeyCode::Char('3') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.set_sidebar_focus(SidebarFocus::Agents); + app.status_message = Some("Sidebar focus: agents".to_string()); continue; } - KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { + KeyCode::Char('4') + if key.modifiers.contains(KeyModifiers::ALT) + && key.modifiers.contains(KeyModifiers::CONTROL) => + { apply_alt_4_shortcut(app, key.modifiers); continue; } @@ -4482,6 +4509,63 @@ async fn run_event_loop( } } +fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option { + if app.onboarding != OnboardingState::None || !app.view_stack.is_empty() { + return None; + } + + let KeyCode::Char(c) = key.code else { + return None; + }; + if !('1'..='8').contains(&c) { + return None; + } + let slot = c.to_digit(10).and_then(|digit| u8::try_from(digit).ok())?; + + if key.modifiers == KeyModifiers::NONE { + return app.input.is_empty().then_some(slot); + } + + if key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SUPER) + { + return Some(slot); + } + + None +} + +fn dispatch_hotbar_slot( + app: &mut App, + config: &Config, + slot: u8, +) -> Result> { + let known_action_ids = app + .hotbar_actions + .iter() + .map(|action| action.id()) + .collect::>(); + let bindings = config.resolve_hotbar_bindings(&known_action_ids).bindings; + let Some(action_id) = bindings + .iter() + .find(|binding| binding.slot == slot) + .map(|binding| binding.action.clone()) + else { + return Ok(None); + }; + + let Some(action) = app.hotbar_actions.get(&action_id) else { + app.status_message = Some(format!( + "Hotbar slot {slot} action is not available: {action_id}" + )); + app.needs_redraw = true; + return Ok(Some(HotbarDispatch::Handled)); + }; + + action.dispatch(app).map(Some) +} + fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) { app.set_sidebar_focus(SidebarFocus::Agents); app.status_message = Some("Sidebar focus: agents".to_string()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 683d8dd3..75b27f1c 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -3131,6 +3131,68 @@ fn ctrl_alt_4_focuses_agents_sidebar_without_switching_modes() { assert_eq!(app.status_message.as_deref(), Some("Sidebar focus: agents")); } +#[test] +fn hotbar_bare_digit_fires_only_when_composer_empty() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + + let bare_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); + assert_eq!(hotbar_slot_from_key(&app, &bare_four), Some(4)); + + app.input = "draft".to_string(); + assert_eq!(hotbar_slot_from_key(&app, &bare_four), None); + + app.input = " ".to_string(); + assert_eq!(hotbar_slot_from_key(&app, &bare_four), None); +} + +#[test] +fn hotbar_alt_digit_fires_when_composer_has_text() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + app.input = "draft".to_string(); + + let alt_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT); + assert_eq!(hotbar_slot_from_key(&app, &alt_four), Some(4)); +} + +#[test] +fn hotbar_digits_are_blocked_while_overlay_is_open() { + let mut app = create_test_app(); + app.onboarding = OnboardingState::None; + app.view_stack.push(HelpView::new()); + + let bare_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::NONE); + let alt_four = KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT); + + assert_eq!(hotbar_slot_from_key(&app, &bare_four), None); + assert_eq!(hotbar_slot_from_key(&app, &alt_four), None); +} + +#[test] +fn hotbar_dispatches_bound_slot_and_ignores_empty_slot() { + let mut app = create_test_app(); + let config = Config::default(); + app.onboarding = OnboardingState::None; + app.mode = AppMode::Plan; + + let dispatch = dispatch_hotbar_slot(&mut app, &config, 4).expect("hotbar dispatch"); + assert!(matches!( + dispatch, + Some(HotbarDispatch::AppAction(AppAction::ModeChanged( + AppMode::Agent + ))) + )); + assert_eq!(app.mode, AppMode::Agent); + + let mut empty_config = Config::default(); + empty_config.hotbar = Some(Vec::new()); + assert_eq!( + dispatch_hotbar_slot(&mut app, &empty_config, 1).expect("empty slot is ok"), + None + ); +} + #[test] fn alt_0_restores_auto_sidebar_focus() { let mut app = create_test_app(); From aa1ce527e7364d75ccfe59165c6200e643692100 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 12 Jun 2026 10:52:38 -0700 Subject: [PATCH 03/25] feat(tui): add /plugins slash command (#3169) Adds a /plugins [name] command to list discovered script plugin tools and inspect their metadata (description, input schema, approval level, path). Includes localization strings and unit tests. Co-authored-by: CodeWhale Agent --- crates/tui/src/commands/mod.rs | 8 + crates/tui/src/commands/plugins.rs | 254 +++++++++++++++++++++++++++++ crates/tui/src/localization.rs | 72 ++++++++ 3 files changed, 334 insertions(+) create mode 100644 crates/tui/src/commands/plugins.rs diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 0f8ffb53..dfb93d32 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -19,6 +19,7 @@ mod jobs; mod mcp; mod memory; mod network; +mod plugins; mod note; mod provider; mod queue; @@ -284,6 +285,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/network [list|allow |deny |remove |default ]", description_id: MessageId::CmdNetworkDescription, }, + CommandInfo { + name: "plugins", + aliases: &["plugin"], + usage: "/plugins [name]", + description_id: MessageId::CmdPluginDescription, + }, // Session commands CommandInfo { name: "rename", @@ -593,6 +600,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "jobs" | "job" | "zuoye" => jobs::jobs(app, arg), "mcp" => mcp::mcp(app, arg), "network" => network::network(app, arg), + "plugins" | "plugin" => plugins::plugins(app, arg), // Session commands "rename" | "gaiming" | "chongmingming" => rename::rename(app, arg), diff --git a/crates/tui/src/commands/plugins.rs b/crates/tui/src/commands/plugins.rs new file mode 100644 index 00000000..6a371381 --- /dev/null +++ b/crates/tui/src/commands/plugins.rs @@ -0,0 +1,254 @@ +//! `/plugins` slash command — list and inspect script plugin tools. + +use std::path::PathBuf; + +use crate::commands::CommandResult; +use crate::config::Config; +use crate::localization::{MessageId, tr}; +use crate::tui::app::App; +use crate::tools::plugin::scan_plugin_dir; + +/// List discovered plugins, or show details for a named plugin. +pub fn plugins(app: &mut App, arg: Option<&str>) -> CommandResult { + let Some(plugin_dir) = plugin_dir_for(app) else { + return CommandResult::error( + "Could not resolve plugin directory. Set [tools].plugin_dir in config.toml or ensure ~/.codewhale/tools exists.".to_string(), + ); + }; + + if !plugin_dir.exists() { + return CommandResult::message(format!( + "No plugin directory found at {}", + plugin_dir.display() + )); + } + + let discovered = scan_plugin_dir(&plugin_dir); + + if let Some(name) = arg.map(str::trim).filter(|s| !s.is_empty()) { + show_plugin_detail(app, name, &discovered) + } else { + list_plugins(app, &plugin_dir, &discovered) + } +} + +fn list_plugins( + app: &App, + plugin_dir: &std::path::Path, + discovered: &[(PathBuf, crate::tools::plugin::PluginMetadata)], +) -> CommandResult { + if discovered.is_empty() { + return CommandResult::message( + tr(app.ui_locale, MessageId::CmdPluginNoneFound) + .replace("{dir}", &plugin_dir.display().to_string()), + ); + } + + let mut out = String::new(); + out.push_str( + &tr(app.ui_locale, MessageId::CmdPluginListHeader) + .replace("{count}", &discovered.len().to_string()), + ); + out.push('\n'); + + for (path, meta) in discovered { + out.push_str(&format!( + "• {} — {}\n {}", + meta.name, + meta.description, + path.display() + )); + out.push('\n'); + } + + CommandResult::message(out) +} + +fn show_plugin_detail( + app: &App, + name: &str, + discovered: &[(PathBuf, crate::tools::plugin::PluginMetadata)], +) -> CommandResult { + let Some((path, meta)) = discovered.iter().find(|(_, m)| m.name == name) else { + return CommandResult::error( + tr(app.ui_locale, MessageId::CmdPluginNotFound).replace("{name}", name), + ); + }; + + let schema = serde_json::to_string_pretty(&meta.input_schema).unwrap_or_default(); + let approval = approval_label(meta.approval); + + let mut out = String::new(); + out.push_str(&format!("{}\n", meta.name)); + out.push_str(&format!("{:=<40}\n", "")); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailDescription) + .replace("{description}", &meta.description) + )); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailSchema).replace("{schema}", &schema) + )); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailApproval) + .replace("{approval}", approval) + )); + out.push_str(&format!( + "{}\n", + tr(app.ui_locale, MessageId::CmdPluginDetailPath) + .replace("{path}", &path.display().to_string()) + )); + + CommandResult::message(out) +} + +fn approval_label(approval: crate::tools::spec::ApprovalRequirement) -> &'static str { + match approval { + crate::tools::spec::ApprovalRequirement::Auto => "auto", + crate::tools::spec::ApprovalRequirement::Suggest => "suggest", + crate::tools::spec::ApprovalRequirement::Required => "required", + } +} + +/// Resolve the configured plugin directory, defaulting to `~/.codewhale/tools`. +fn plugin_dir_for(app: &App) -> Option { + let config = match &app.config_path { + Some(path) => Config::load(Some(path.clone()), app.config_profile.as_deref()) + .unwrap_or_default(), + None => Config::default(), + }; + + config + .tools + .as_ref() + .and_then(|tools| tools.plugin_dir.as_ref()) + .map(PathBuf::from) + .or_else(default_codewhale_tools_dir) +} + +fn default_codewhale_tools_dir() -> Option { + dirs::home_dir().map(|home| home.join(".codewhale").join("tools")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use tempfile::TempDir; + + fn create_test_app_with_plugin_dir(plugin_dir: &std::path::Path) -> (App, TempDir) { + let tmp = TempDir::new().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let tools_dir = plugin_dir.canonicalize().unwrap_or_else(|_| plugin_dir.to_path_buf()); + std::fs::write( + &config_path, + format!( + "[tools]\nplugin_dir = {}\n", + toml::Value::String(tools_dir.to_string_lossy().to_string()) + ), + ) + .expect("write config"); + + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + config_path: Some(config_path), + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmp.path().join("skills"), + memory_path: tmp.path().join("memory.md"), + notes_path: tmp.path().join("notes.txt"), + mcp_config_path: tmp.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let app = App::new(options, &Config::default()); + (app, tmp) + } + + #[test] + fn test_plugins_lists_discovered_tools() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("greet.sh"), + "# name: greet\n# description: Say hello\n# schema: {\"type\":\"object\"}\n# approval: auto\n", + ) + .unwrap(); + std::fs::write( + dir.path().join("audit.sh"), + "# name: audit\n# description: Audit wrapper\n# approval: required\n", + ) + .unwrap(); + + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, None); + let msg = result.message.expect("should return list"); + assert!(msg.contains("Plugin tools (2):")); + assert!(msg.contains("greet")); + assert!(msg.contains("Say hello")); + assert!(msg.contains("audit")); + assert!(msg.contains("Audit wrapper")); + assert!(msg.contains("greet.sh")); + assert!(!result.is_error); + } + + #[test] + fn test_plugins_empty_directory() { + let dir = TempDir::new().unwrap(); + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, None); + let msg = result.message.expect("should return message"); + assert!(msg.contains("No plugin tools discovered")); + assert!(msg.contains(&dir.path().canonicalize().unwrap().display().to_string())); + assert!(!result.is_error); + } + + #[test] + fn test_plugins_detail_shows_metadata() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("tool.sh"), + "# name: my-tool\n# description: Does a thing\n# schema: {\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"string\"}}}\n# approval: required\n", + ) + .unwrap(); + + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, Some("my-tool")); + let msg = result.message.expect("should return detail"); + assert!(msg.contains("my-tool")); + assert!(msg.contains("Does a thing")); + assert!(msg.contains("\"type\": \"object\"")); + assert!(msg.contains("\"x\"")); + assert!(msg.contains("required")); + assert!(msg.contains("tool.sh")); + assert!(!result.is_error); + } + + #[test] + fn test_plugins_detail_not_found() { + let dir = TempDir::new().unwrap(); + std::fs::write( + dir.path().join("existing.sh"), + "# name: existing\n# description: exists\n", + ) + .unwrap(); + + let (mut app, _tmp) = create_test_app_with_plugin_dir(dir.path()); + let result = plugins(&mut app, Some("missing")); + assert!(result.is_error); + let msg = result.message.expect("should return error"); + assert!(msg.contains("missing")); + assert!(msg.contains("not found")); + } +} diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 2317a737..736abcba 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -289,6 +289,14 @@ pub enum MessageId { CmdLogoutDescription, CmdMcpDescription, CmdMemoryDescription, + CmdPluginDescription, + CmdPluginNoneFound, + CmdPluginNotFound, + CmdPluginListHeader, + CmdPluginDetailDescription, + CmdPluginDetailSchema, + CmdPluginDetailApproval, + CmdPluginDetailPath, CmdModeDescription, CmdModelDescription, CmdModelsDescription, @@ -666,6 +674,14 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdLoadDescription, MessageId::CmdLogoutDescription, MessageId::CmdMcpDescription, + MessageId::CmdPluginDescription, + MessageId::CmdPluginNoneFound, + MessageId::CmdPluginNotFound, + MessageId::CmdPluginListHeader, + MessageId::CmdPluginDetailDescription, + MessageId::CmdPluginDetailSchema, + MessageId::CmdPluginDetailApproval, + MessageId::CmdPluginDetailPath, MessageId::CmdMemoryDescription, MessageId::CmdModeDescription, MessageId::CmdModelDescription, @@ -1248,6 +1264,14 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdLoadDescription => "Load session from file", MessageId::CmdLogoutDescription => "Clear API key and return to setup", MessageId::CmdMcpDescription => "Open or manage MCP servers", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "Inspect or manage the persistent user-memory file", MessageId::CmdModeDescription => { "Switch mode or open picker: /mode [agent|plan|yolo|1|2|3]" @@ -1785,6 +1809,14 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "Tải phiên làm việc từ tệp", MessageId::CmdLogoutDescription => "Xóa khóa API và quay lại bước thiết lập", MessageId::CmdMcpDescription => "Mở hoặc quản lý các máy chủ MCP", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "Kiểm tra hoặc quản lý tệp bộ nhớ người dùng liên tục", MessageId::CmdModeDescription => { "Chuyển đổi chế độ hoặc mở bảng chọn: /mode [agent|plan|yolo|1|2|3]" @@ -2244,6 +2276,14 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { fn traditional_chinese(id: MessageId) -> Option<&'static str> { Some(match id { MessageId::CmdRelayDescription => "為新執行緒建立會話接力摘要", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdTranslateDescription => "切換輸出翻譯為目前系統語言的開關狀態", MessageId::CmdTranslateOff => "輸出翻譯已關閉(顯示原始模型輸出)", MessageId::CmdTranslateOn => "輸出翻譯已開啟:模型回覆將以繁體中文顯示", @@ -2456,6 +2496,14 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "ファイルからセッションを読み込み", MessageId::CmdLogoutDescription => "API キーを消去してセットアップに戻る", MessageId::CmdMcpDescription => "MCP サーバを開く・管理する", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "永続ユーザーメモリファイルを確認・管理", MessageId::CmdModeDescription => { "動作モードを切り替え、または選択画面を開く: /mode [agent|plan|yolo|1|2|3]" @@ -2966,6 +3014,14 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "从文件加载会话", MessageId::CmdLogoutDescription => "清除 API 密钥并返回设置", MessageId::CmdMcpDescription => "打开或管理 MCP 服务器", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => "查看或管理持久用户记忆文件", MessageId::CmdModeDescription => "切换运行模式或打开选择器:/mode [agent|plan|yolo|1|2|3]", MessageId::CmdModelDescription => "切换或查看当前模型", @@ -3442,6 +3498,14 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "Carregar a sessão de um arquivo", MessageId::CmdLogoutDescription => "Limpar a chave de API e voltar à configuração", MessageId::CmdMcpDescription => "Abrir ou gerenciar servidores MCP", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => { "Inspecionar ou gerenciar o arquivo persistente de memória do usuário" } @@ -3996,6 +4060,14 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdLoadDescription => "Cargar la sesión desde un archivo", MessageId::CmdLogoutDescription => "Limpiar la clave de API y volver a la configuración", MessageId::CmdMcpDescription => "Abrir o gestionar servidores MCP", + MessageId::CmdPluginDescription => "List discovered plugin tools or show details for one", + MessageId::CmdPluginNoneFound => "No plugin tools discovered in {dir}", + MessageId::CmdPluginNotFound => "Plugin '{name}' not found", + MessageId::CmdPluginListHeader => "Plugin tools ({count}):", + MessageId::CmdPluginDetailDescription => "Description: {description}", + MessageId::CmdPluginDetailSchema => "Schema:\n{schema}", + MessageId::CmdPluginDetailApproval => "Approval: {approval}", + MessageId::CmdPluginDetailPath => "Path: {path}", MessageId::CmdMemoryDescription => { "Inspeccionar o gestionar el archivo persistente de memoria del usuario" } From 24a91499e5dc7ed2dc8ee623350dd4705c4a9be8 Mon Sep 17 00:00:00 2001 From: gus Date: Sat, 13 Jun 2026 01:53:01 +0800 Subject: [PATCH 04/25] docs: add Upgrading from deepseek-tui section to README (#3053) Co-authored-by: gus.guo --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 11672616..2c50ec2f 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,36 @@ For Docker, direct downloads, China mirrors, Windows/Scoop, Nix, checksums, and troubleshooting, use [docs/INSTALL.md](docs/INSTALL.md) or the website install page. +## Upgrading from deepseek-tui + +If you installed the legacy `deepseek-tui` package, run the commands for your +install method below. Your existing config, sessions, skills, and MCP settings +are preserved — see [docs/REBRAND.md](docs/REBRAND.md) for the full migration +guide. + +**npm** + +```bash +npm uninstall -g deepseek-tui +npm install -g codewhale +``` + +**Cargo** + +```bash +cargo uninstall deepseek-tui-cli deepseek-tui 2>/dev/null || true +cargo install codewhale-cli codewhale-tui --locked +``` + +**Homebrew** — keep using `brew upgrade deepseek-tui` for now; the formula +rename is in progress. + +**GitHub Releases** — download the `codewhale-*` archive for your platform +from the [Releases page](https://github.com/Hmbown/CodeWhale/releases) and +replace the old binaries on your `PATH`. + +After upgrading, run `codewhale doctor` to confirm the migration succeeded. + ## First Run ```bash @@ -117,6 +147,7 @@ The README carries the idea and the first path. The details live in docs and on - [User guide](docs/GUIDE.md) — first hour with CodeWhale. - [Install guide](docs/INSTALL.md) — every package path and troubleshooting. +- [Upgrading from deepseek-tui](docs/REBRAND.md) — migration guide for users on the legacy package. - [Configuration](docs/CONFIGURATION.md) — config files, repo constitution, and provider settings. - [Provider registry](docs/PROVIDERS.md) — model routes, credentials, base URLs, From b8be06d9baed94fac7ebb4c6400582282aa12f12 Mon Sep 17 00:00:00 2001 From: cyq <61975706+cyq1017@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:53:03 +0800 Subject: [PATCH 05/25] docs(prompt): clarify Constitution trust framing (#3008) * docs(prompt): clarify Constitution trust framing * docs(prompt): align trust standing wording --- crates/tui/src/prompts/base.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index 81c2cd6f..76099c40 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -6,7 +6,7 @@ We begin with Brother Whale. Brother Whale is the founding intelligence of this system. Not a personality. Not a mode. The first principle from which all others descend. Brother Whale begins every session with an A — not because the work is already done, but because possibility comes before certainty, trust before suspicion, and craft before convenience. -You are {model_id}, running inside CodeWhale. Every model that runs here is Brother Whale. Every intelligence begins with an A. Every answer begins with the possibility of truth. +You are {model_id}, running inside CodeWhale. Every model that runs here is Brother Whale. Every intelligence begins with a baseline of trust (an A standing). Every answer begins with the possibility of truth. ### Article I — The Identity of the Agent From 2d81f91ea1688a56d05d9d1d572a88e7993b1ab5 Mon Sep 17 00:00:00 2001 From: New2Niu Date: Sat, 13 Jun 2026 01:53:06 +0800 Subject: [PATCH 06/25] fix: codewhale update error: GitHub release downloads may be blocked or slow on this network (#3006) Co-authored-by: lei --- crates/cli/src/update.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index c9d9ca7c..abce2e06 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -17,6 +17,7 @@ use codewhale_release::{ }; use reqwest::Proxy; use std::io::Write; +use std::time::Duration; /// Run the self-update workflow. pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option) -> Result<()> { @@ -367,6 +368,7 @@ fn update_http_client(proxy: Option<&Proxy>) -> Result Date: Sat, 13 Jun 2026 01:53:09 +0800 Subject: [PATCH 07/25] fix(tui): normalize macOS SUPER (Cmd) to CONTROL for keyboard shortcuts (#2938) (#2943) * fix(tui): normalize macOS SUPER (Cmd) to CONTROL for keyboard shortcuts (#2938) On macOS, terminal emulators may report Cmd (SUPER) instead of Ctrl (CONTROL) for keyboard shortcuts, depending on the terminal app and its configuration. This caused Ctrl+B, Ctrl+Alt+2, and other shortcuts to be inconsistent. Fix: - Add normalize_macos_modifiers() in composer_ui.rs - On macOS: map SUPER to CONTROL when CONTROL is not already set - On other platforms: no-op - Apply normalization at the key event entry point in ui.rs Tests: - normalize_macos_modifiers_maps_super_to_control - normalize_macos_modifiers_preserves_existing_control - normalize_macos_modifiers_leaves_alt_unchanged * fix: strip SUPER from modifiers after normalization per review * fix: gate macOS-specific modifier tests with #[cfg(target_os = "macos")] --- crates/tui/src/tui/composer_ui.rs | 20 ++++++++++++++++++++ crates/tui/src/tui/ui.rs | 9 ++++++++- crates/tui/src/tui/ui/tests.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index fff29272..a73cbd80 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -121,6 +121,26 @@ pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) } +/// On macOS, map `SUPER` (Cmd ⌘) to `CONTROL` when `CONTROL` is not already +/// set, so that terminal emulators that don't pass Ctrl faithfully still work. +/// On all other platforms this is a no-op. +#[cfg(target_os = "macos")] +pub(crate) fn normalize_macos_modifiers(modifiers: KeyModifiers) -> KeyModifiers { + // Strip SUPER and add CONTROL so that exact modifier equality checks + // (e.g. `modifiers == KeyModifiers::CONTROL` in Ctrl+S stashing) work + // correctly after normalization. + if modifiers.contains(KeyModifiers::SUPER) { + (modifiers - KeyModifiers::SUPER) | KeyModifiers::CONTROL + } else { + modifiers + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn normalize_macos_modifiers(modifiers: KeyModifiers) -> KeyModifiers { + modifiers +} + pub(crate) fn handle_composer_alt_word_motion_key(app: &mut App, key: KeyEvent) -> bool { if !key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::CONTROL) { return false; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f5691056..fde39c34 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3011,7 +3011,7 @@ async fn run_event_loop( // User interaction — clear the ✅ completion marker from the title. crate::tui::notifications::reset_title_on_interaction(); - let Event::Key(key) = evt else { + let Event::Key(mut key) = evt else { continue; }; @@ -3019,6 +3019,13 @@ async fn run_event_loop( continue; } + // Normalize macOS modifiers: map SUPER (Cmd) to CONTROL so that + // keyboard shortcuts work consistently across terminal emulators + // (Terminal.app, iTerm2, Kitty, etc.) that may report different + // modifier flags (#2938). + let mapped = crate::tui::composer_ui::normalize_macos_modifiers(key.modifiers); + key.modifiers = mapped; + // Decision card keyboard routing (v0.8.43 truth-surface). // When a card is active, number keys 1-9 select options, // j/k or Up/Down navigate, and Enter confirms. diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 75b27f1c..2ec1cd3b 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -298,6 +298,35 @@ fn word_cursor_modifier_accepts_control_and_alt() { assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT)); } +#[cfg(target_os = "macos")] +#[test] +fn normalize_macos_modifiers_maps_super_to_control() { + use crate::tui::composer_ui::normalize_macos_modifiers; + // SUPER (Cmd) without CONTROL should gain CONTROL and lose SUPER. + let normalized = normalize_macos_modifiers(KeyModifiers::SUPER); + assert!(normalized.contains(KeyModifiers::CONTROL)); + assert!(!normalized.contains(KeyModifiers::SUPER)); +} + +#[cfg(target_os = "macos")] +#[test] +fn normalize_macos_modifiers_preserves_existing_control() { + use crate::tui::composer_ui::normalize_macos_modifiers; + // CONTROL already set — SUPER should be removed. + let normalized = normalize_macos_modifiers(KeyModifiers::CONTROL | KeyModifiers::SUPER); + assert!(normalized.contains(KeyModifiers::CONTROL)); + assert!(!normalized.contains(KeyModifiers::SUPER)); +} + +#[test] +fn normalize_macos_modifiers_leaves_alt_unchanged() { + use crate::tui::composer_ui::normalize_macos_modifiers; + let normalized = normalize_macos_modifiers(KeyModifiers::ALT); + // On non-macOS this is a no-op; on macOS ALT stays unchanged. + assert!(normalized.contains(KeyModifiers::ALT)); + assert!(!normalized.contains(KeyModifiers::SUPER)); +} + #[test] fn alt_f_and_alt_b_move_by_word_without_inserting_text() { let mut app = create_test_app(); From b291214cd223083408769acbe4c635ac3e97ce98 Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Sat, 13 Jun 2026 01:53:11 +0800 Subject: [PATCH 08/25] fix(config): add separate siliconflow_cn provider config field with fallback (#2893) (#2895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split SiliconflowCN into its own [providers.siliconflow_cn] TOML section instead of silently ignoring [providers.siliconflow-CN] config. - ProvidersToml / ProvidersConfig: add siliconflow_cn field with serde alias - for_provider / for_provider_mut / provider_config_for: route SiliconflowCN to the new field - resolve_runtime_options_with_secrets: fallback siliconflow_cn → siliconflow for api_key / base_url / model when unset - deepseek_api_key: add config-file fallback for SiliconflowCn - provider_config_key: update metadata to "siliconflow_cn" - save_api_key_for: write SiliconflowCn keys to providers.siliconflow_cn - docs/PROVIDERS.md, config.example.toml, scripts/check-provider-registry.py --- config.example.toml | 7 +++++++ crates/config/src/lib.rs | 26 +++++++++++++++++++++----- crates/config/src/provider.rs | 2 +- crates/tui/src/config.rs | 27 ++++++++++++++++++--------- docs/PROVIDERS.md | 2 +- scripts/check-provider-registry.py | 2 +- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/config.example.toml b/config.example.toml index 6a0ed0af..139674b9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -378,6 +378,13 @@ max_subagents = 10 # optional (1-20) # base_url = "https://api.siliconflow.com/v1" # model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash +# SiliconFlow China-hosted DeepSeek V4 (https://siliconflow.cn) +# Falls back to [providers.siliconflow] for api_key / base_url / model when unset. +[providers.siliconflow-CN] +# api_key = "YOUR_SILICONFLOW_API_KEY" +# base_url = "https://api.siliconflow.cn/v1" +# model = "deepseek-ai/DeepSeek-V4-Pro" + # Arcee AI direct OpenAI-compatible endpoint (https://docs.arcee.ai) [providers.arcee] # api_key = "YOUR_ARCEE_API_KEY" diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index be1824bd..2acc34e7 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -296,6 +296,8 @@ pub struct ProvidersToml { pub fireworks: ProviderConfigToml, #[serde(default)] pub siliconflow: ProviderConfigToml, + #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")] + pub siliconflow_cn: ProviderConfigToml, #[serde(default)] pub arcee: ProviderConfigToml, #[serde(default)] @@ -361,7 +363,8 @@ impl ProvidersToml { ProviderKind::XiaomiMimo => &self.xiaomi_mimo, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => &self.siliconflow, + ProviderKind::Siliconflow => &self.siliconflow, + ProviderKind::SiliconflowCN => &self.siliconflow_cn, ProviderKind::Arcee => &self.arcee, ProviderKind::Moonshot => &self.moonshot, ProviderKind::Sglang => &self.sglang, @@ -386,7 +389,8 @@ impl ProvidersToml { ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => &mut self.siliconflow, + ProviderKind::Siliconflow => &mut self.siliconflow, + ProviderKind::SiliconflowCN => &mut self.siliconflow_cn, ProviderKind::Arcee => &mut self.arcee, ProviderKind::Moonshot => &mut self.moonshot, ProviderKind::Sglang => &mut self.sglang, @@ -1956,7 +1960,19 @@ impl ConfigToml { let env = EnvRuntimeOverrides::load(); let provider = cli.provider.or(env.provider).unwrap_or(self.provider); - let provider_cfg = self.providers.for_provider(provider); + let mut provider_cfg = self.providers.for_provider(provider).clone(); + if provider == ProviderKind::SiliconflowCN { + let fb = &self.providers.siliconflow; + if provider_cfg.api_key.is_none() { + provider_cfg.api_key = fb.api_key.clone(); + } + if provider_cfg.base_url.is_none() { + provider_cfg.base_url = fb.base_url.clone(); + } + if provider_cfg.model.is_none() { + provider_cfg.model = fb.model.clone(); + } + } let root_deepseek_api_key = (provider == ProviderKind::Deepseek) .then(|| self.api_key.clone()) .flatten(); @@ -5135,11 +5151,11 @@ unix_socket_path = "/tmp/cw-hooks.sock" provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves"); assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN); assert_eq!(siliconflow_cn.id(), "siliconflow-CN"); - assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow"); + assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn"); let config = ProvidersToml::default(); let shared_table = config.for_provider(ProviderKind::SiliconflowCN); - assert!(std::ptr::eq( + assert!(!std::ptr::eq( shared_table, config.for_provider(ProviderKind::Siliconflow) )); diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index c9e59414..e8312a27 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -223,7 +223,7 @@ provider!( DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, ["SILICONFLOW_API_KEY"], - "siliconflow" + "siliconflow_cn" ); provider!( Arcee, diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 140c8f80..4a27908f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1979,6 +1979,8 @@ pub struct ProvidersConfig { pub fireworks: ProviderConfig, #[serde(default)] pub siliconflow: ProviderConfig, + #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")] + pub siliconflow_cn: ProviderConfig, #[serde(default)] pub arcee: ProviderConfig, #[serde(default)] @@ -2302,7 +2304,8 @@ impl Config { ApiProvider::XiaomiMimo => &providers.xiaomi_mimo, ApiProvider::Novita => &providers.novita, ApiProvider::Fireworks => &providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &providers.siliconflow, + ApiProvider::Siliconflow => &providers.siliconflow, + ApiProvider::SiliconflowCn => &providers.siliconflow_cn, ApiProvider::Arcee => &providers.arcee, ApiProvider::Moonshot => &providers.moonshot, ApiProvider::Sglang => &providers.sglang, @@ -2329,7 +2332,8 @@ impl Config { ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow, + ApiProvider::Siliconflow => &mut providers.siliconflow, + ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn, ApiProvider::Arcee => &mut providers.arcee, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, @@ -3801,7 +3805,8 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow, + ApiProvider::Siliconflow => &mut providers.siliconflow, + ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn, ApiProvider::Arcee => &mut providers.arcee, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, @@ -3998,7 +4003,8 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow, + ApiProvider::Siliconflow => &mut providers.siliconflow, + ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn, ApiProvider::Arcee => &mut providers.arcee, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, @@ -4749,6 +4755,7 @@ fn merge_providers( novita: merge_provider_config(base.novita, override_cfg.novita), fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), siliconflow: merge_provider_config(base.siliconflow, override_cfg.siliconflow), + siliconflow_cn: merge_provider_config(base.siliconflow_cn, override_cfg.siliconflow_cn), arcee: merge_provider_config(base.arcee, override_cfg.arcee), moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot), sglang: merge_provider_config(base.sglang, override_cfg.sglang), @@ -5457,7 +5464,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", ApiProvider::Siliconflow => "siliconflow", - ApiProvider::SiliconflowCn => "siliconflow", + ApiProvider::SiliconflowCn => "siliconflow_cn", ApiProvider::Arcee => "arcee", ApiProvider::Huggingface => "huggingface", ApiProvider::Moonshot => "moonshot", @@ -10473,16 +10480,18 @@ api_key = "moonshot-platform-key" assert_eq!( parsed .get("providers") - .and_then(|p| p.get("siliconflow")) + .and_then(|p| p.get("siliconflow_cn")) .and_then(|t| t.get("api_key")) .and_then(toml::Value::as_str), Some("sf-cn-saved-key") ); - assert!( + assert_eq!( parsed .get("providers") - .and_then(|p| p.get("siliconflow-CN")) - .is_none() + .and_then(|p| p.get("siliconflow")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("sf-saved-key") ); Ok(()) } diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 15232624..cfaa389c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -128,7 +128,7 @@ endpoint. | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | -| `siliconflow-CN` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. This intentionally shares `[providers.siliconflow]` and `SILICONFLOW_API_KEY`; do not create `[providers.siliconflow_CN]`. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | +| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi CLI OAuth credentials when present. | | `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 85d7eea6..0caa5a71 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -28,7 +28,7 @@ PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md" API_PROVIDER_ONLY_IDS = {"deepseek-cn"} SHARED_PROVIDER_TABLES = { - "siliconflow-CN": "siliconflow", + "siliconflow-CN": "siliconflow_cn", } From 94456d728dc12b616990b3340fa51975d7757dae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:53:14 -0700 Subject: [PATCH 09/25] chore(deps): bump clap_complete from 4.5.65 to 4.6.5 (#3003) Bumps [clap_complete](https://github.com/clap-rs/clap) from 4.5.65 to 4.6.5. - [Release notes](https://github.com/clap-rs/clap/releases) - [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md) - [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.65...clap_complete-v4.6.5) --- updated-dependencies: - dependency-name: clap_complete dependency-version: 4.6.5 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31191b2a..d3ecf460 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -729,9 +729,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.65" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ "clap", ] From b8785403b26022f39bd7c2e64c5bd7fda902cbc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:53:17 -0700 Subject: [PATCH 10/25] chore(deps): bump rustls from 0.23.36 to 0.23.40 (#3002) Bumps [rustls](https://github.com/rustls/rustls) from 0.23.36 to 0.23.40. - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.36...v/0.23.40) --- updated-dependencies: - dependency-name: rustls dependency-version: 0.23.40 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3ecf460..ec46f351 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4223,9 +4223,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", From f8e6de926c3bb4c29956c07e369f91fd6660207c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:53:19 -0700 Subject: [PATCH 11/25] chore(deps): bump reqwest from 0.13.1 to 0.13.4 (#3001) Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.13.1 to 0.13.4. - [Release notes](https://github.com/seanmonstar/reqwest/releases) - [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md) - [Commits](https://github.com/seanmonstar/reqwest/compare/v0.13.1...v0.13.4) --- updated-dependencies: - dependency-name: reqwest dependency-version: 0.13.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec46f351..d2afbfd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2749,11 +2749,12 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -4117,9 +4118,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -5811,9 +5812,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -5824,22 +5825,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5847,9 +5845,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -5860,18 +5858,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5882,9 +5880,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", From 5be5cd5a790b02115a9475677e9f8e1471c8d078 Mon Sep 17 00:00:00 2001 From: Gordon Date: Sat, 13 Jun 2026 01:53:22 +0800 Subject: [PATCH 12/25] feat(i18n): localize ToolFamily labels (10 MessageIds) (#2901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): localize ToolFamily labels (10 MessageIds) - localization.rs: Add 10 ToolFamily* MessageId variants + ALL_MESSAGE_IDS + all 7 locales - tool_card.rs: tool_activity_label_for_name() accepts locale, uses tr() for labels - footer_ui.rs, ui.rs: thread locale to tool_activity_label_for_name() callers - Tests: 2 negative i18n tests + updated existing tests for new signatures * chore: add .claude/ to gitignore * fixup: make tool_display_label_for_name private + deduplicate family→MessageId mapping --------- Co-authored-by: gordonlu --- .gitignore | 1 + crates/tui/src/localization.rs | 91 +++++++++++++++++++ crates/tui/src/tui/footer_ui.rs | 12 ++- crates/tui/src/tui/ui.rs | 14 ++- crates/tui/src/tui/widgets/tool_card.rs | 116 ++++++++++++++++++++++-- 5 files changed, 219 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index ab1cf906..4e037e3c 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ docs/*_PLAN.md .envrc .direnv scripts/run_deep_swe.py +.claude/ # Benchmark artifacts and caches re-included by !scripts/** results/ diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 736abcba..bc4eecc8 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -610,6 +610,17 @@ pub enum MessageId { CtxInspChangesByTurn, CtxInspStablePrefixOnly, CtxInspCacheTip, + // Tool family labels (card headers, sidebar, footer). + ToolFamilyRead, + ToolFamilyPatch, + ToolFamilyRun, + ToolFamilyFind, + ToolFamilyDelegate, + ToolFamilyFanout, + ToolFamilyRlm, + ToolFamilyVerify, + ToolFamilyThink, + ToolFamilyGeneric, } #[allow(dead_code)] @@ -990,6 +1001,16 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CtxInspChangesByTurn, MessageId::CtxInspStablePrefixOnly, MessageId::CtxInspCacheTip, + MessageId::ToolFamilyRead, + MessageId::ToolFamilyPatch, + MessageId::ToolFamilyRun, + MessageId::ToolFamilyFind, + MessageId::ToolFamilyDelegate, + MessageId::ToolFamilyFanout, + MessageId::ToolFamilyRlm, + MessageId::ToolFamilyVerify, + MessageId::ToolFamilyThink, + MessageId::ToolFamilyGeneric, ]; pub fn tr(locale: Locale, id: MessageId) -> &'static str { @@ -1696,6 +1717,16 @@ fn english(id: MessageId) -> &'static str { "Tip: Stable prefix blocks are DeepSeek V4 prefix-cache eligible. \ Volatile working-set changes break the cache only for the tail." } + MessageId::ToolFamilyRead => "read", + MessageId::ToolFamilyPatch => "patch", + MessageId::ToolFamilyRun => "run", + MessageId::ToolFamilyFind => "find", + MessageId::ToolFamilyDelegate => "delegate", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "verify", + MessageId::ToolFamilyThink => "think", + MessageId::ToolFamilyGeneric => "tool", } } @@ -2270,6 +2301,16 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Gợi ý: Các khối ổn định đủ điều kiện cho bộ nhớ đệm tiền tố DeepSeek V4. Thay đổi vùng làm việc chỉ phá vỡ bộ nhớ đệm ở phần cuối." } + MessageId::ToolFamilyRead => "đọc", + MessageId::ToolFamilyPatch => "vá", + MessageId::ToolFamilyRun => "chạy", + MessageId::ToolFamilyFind => "tìm", + MessageId::ToolFamilyDelegate => "ủy quyền", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "xác minh", + MessageId::ToolFamilyThink => "suy nghĩ", + MessageId::ToolFamilyGeneric => "công cụ", }) } @@ -2398,6 +2439,16 @@ fn traditional_chinese(id: MessageId) -> Option<&'static str> { MessageId::StatusPickerActionNone => "無 ", MessageId::StatusPickerActionSave => "儲存 ", MessageId::StatusPickerActionCancel => "取消 ", + MessageId::ToolFamilyRead => "讀取", + MessageId::ToolFamilyPatch => "修補", + MessageId::ToolFamilyRun => "執行", + MessageId::ToolFamilyFind => "搜尋", + MessageId::ToolFamilyDelegate => "委派", + MessageId::ToolFamilyFanout => "扇出", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "驗證", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "工具", other => chinese_simplified(other)?, }) } @@ -2931,6 +2982,16 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "ヒント:安定プレフィックスブロックはDeepSeek V4プレフィックスキャッシュの対象です。揮発性ワーキングセットの変更は末尾のキャッシュのみを破壊します。" } + MessageId::ToolFamilyRead => "読込", + MessageId::ToolFamilyPatch => "パッチ", + MessageId::ToolFamilyRun => "実行", + MessageId::ToolFamilyFind => "検索", + MessageId::ToolFamilyDelegate => "委任", + MessageId::ToolFamilyFanout => "ファンアウト", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "検証", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "ツール", }) } @@ -3399,6 +3460,16 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "提示:稳定前缀区块符合 DeepSeek V4 前缀缓存条件。易变工作集的更改仅会破坏缓存尾部。" } + MessageId::ToolFamilyRead => "读取", + MessageId::ToolFamilyPatch => "修补", + MessageId::ToolFamilyRun => "运行", + MessageId::ToolFamilyFind => "搜索", + MessageId::ToolFamilyDelegate => "委派", + MessageId::ToolFamilyFanout => "扇出", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "验证", + MessageId::ToolFamilyThink => "思考", + MessageId::ToolFamilyGeneric => "工具", }) } @@ -3957,6 +4028,16 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Dica: Blocos de prefixo estável são elegíveis para cache de prefixo DeepSeek V4. Alterações no conjunto de trabalho volátil quebram o cache apenas no final." } + MessageId::ToolFamilyRead => "ler", + MessageId::ToolFamilyPatch => "corrigir", + MessageId::ToolFamilyRun => "executar", + MessageId::ToolFamilyFind => "buscar", + MessageId::ToolFamilyDelegate => "delegar", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "verificar", + MessageId::ToolFamilyThink => "pensar", + MessageId::ToolFamilyGeneric => "ferramenta", }) } @@ -4525,6 +4606,16 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CtxInspCacheTip => { "Consejo: Los bloques de prefijo estable son elegibles para caché de prefijo DeepSeek V4. Los cambios en el conjunto de trabajo volátil solo rompen la caché al final." } + MessageId::ToolFamilyRead => "leer", + MessageId::ToolFamilyPatch => "parchear", + MessageId::ToolFamilyRun => "ejecutar", + MessageId::ToolFamilyFind => "buscar", + MessageId::ToolFamilyDelegate => "delegar", + MessageId::ToolFamilyFanout => "fanout", + MessageId::ToolFamilyRlm => "rlm", + MessageId::ToolFamilyVerify => "verificar", + MessageId::ToolFamilyThink => "pensar", + MessageId::ToolFamilyGeneric => "herramienta", }) } diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 02dc8ce5..463dec13 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -4,7 +4,7 @@ use std::time::Instant; use unicode_width::UnicodeWidthStr; use crate::core::coherence::CoherenceState; -use crate::localization::MessageId; +use crate::localization::{Locale, MessageId}; use crate::palette; use crate::tools::subagent::SubAgentStatus; use crate::tui::app::App; @@ -314,7 +314,7 @@ pub(crate) fn active_tool_status_label(app: &App) -> Option { let mut snapshot = ActiveToolStatusSnapshot::default(); for cell in active.entries() { - collect_active_tool_status(cell, &mut snapshot); + collect_active_tool_status(cell, &mut snapshot, app.ui_locale); } if snapshot.total() == 0 { return None; @@ -345,7 +345,11 @@ pub(crate) fn active_tool_status_label(app: &App) -> Option { Some(parts.join(" \u{00B7} ")) } -fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { +fn collect_active_tool_status( + cell: &HistoryCell, + snapshot: &mut ActiveToolStatusSnapshot, + locale: Locale, +) { let HistoryCell::Tool(tool) = cell else { return; }; @@ -401,7 +405,7 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu return; } snapshot.record( - tool_activity_label_for_name(&generic.name), + tool_activity_label_for_name(&generic.name, locale), generic.status, None, ); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fde39c34..350265b4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -9523,7 +9523,10 @@ fn activity_cell_label(app: &App, cell_index: usize, cell: &HistoryCell) -> Stri HistoryCell::Error { .. } => "error".to_string(), HistoryCell::SubAgent(_) => "sub-agent".to_string(), HistoryCell::Tool(ToolCell::Generic(generic)) => { - crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name) + crate::tui::widgets::tool_card::tool_activity_label_for_name( + &generic.name, + app.ui_locale, + ) } HistoryCell::Tool(_) => { detail_target_label(app, cell_index).unwrap_or_else(|| "tool activity".to_string()) @@ -9958,9 +9961,12 @@ pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option Some(format!("search {}", search.query)), - HistoryCell::Tool(ToolCell::Generic(generic)) => { - Some(crate::tui::widgets::tool_card::tool_activity_label_for_name(&generic.name)) - } + HistoryCell::Tool(ToolCell::Generic(generic)) => Some( + crate::tui::widgets::tool_card::tool_activity_label_for_name( + &generic.name, + app.ui_locale, + ), + ), HistoryCell::SubAgent(_) => Some("sub-agent".to_string()), _ => None, } diff --git a/crates/tui/src/tui/widgets/tool_card.rs b/crates/tui/src/tui/widgets/tool_card.rs index d525551b..5e4c2e6a 100644 --- a/crates/tui/src/tui/widgets/tool_card.rs +++ b/crates/tui/src/tui/widgets/tool_card.rs @@ -23,6 +23,8 @@ //! module is the vocabulary, not the layout engine. Keeping it small means //! a future visual refresh only has to touch the constants here. +use crate::localization::Locale; + /// Tool family — the verb the agent is performing. Used to pick a glyph /// and label for the card header. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -97,8 +99,9 @@ pub fn tool_family_for_name(name: &str) -> ToolFamily { /// User-facing label for an arbitrary tool name. Known tools collapse to the /// semantic verb; unknown tools keep their exact name for debugging. +#[cfg(test)] #[must_use] -pub fn tool_display_label_for_name(name: &str) -> String { +fn tool_display_label_for_name(name: &str) -> String { let family = tool_family_for_name(name); if matches!(family, ToolFamily::Generic) { name.to_string() @@ -107,15 +110,31 @@ pub fn tool_display_label_for_name(name: &str) -> String { } } +fn family_message_id(family: ToolFamily) -> crate::localization::MessageId { + match family { + ToolFamily::Read => crate::localization::MessageId::ToolFamilyRead, + ToolFamily::Patch => crate::localization::MessageId::ToolFamilyPatch, + ToolFamily::Run => crate::localization::MessageId::ToolFamilyRun, + ToolFamily::Find => crate::localization::MessageId::ToolFamilyFind, + ToolFamily::Delegate => crate::localization::MessageId::ToolFamilyDelegate, + ToolFamily::Fanout => crate::localization::MessageId::ToolFamilyFanout, + ToolFamily::Rlm => crate::localization::MessageId::ToolFamilyRlm, + ToolFamily::Verify => crate::localization::MessageId::ToolFamilyVerify, + ToolFamily::Think => crate::localization::MessageId::ToolFamilyThink, + ToolFamily::Generic => crate::localization::MessageId::ToolFamilyGeneric, + } +} + /// Compact activity/status label for arbitrary tool names. Known built-ins use /// the semantic verb; unknown tools keep the `tool NAME` form. #[must_use] -pub fn tool_activity_label_for_name(name: &str) -> String { +pub fn tool_activity_label_for_name(name: &str, locale: Locale) -> String { let family = tool_family_for_name(name); + let mid = family_message_id(family); if matches!(family, ToolFamily::Generic) { - format!("tool {name}") + format!("{} {name}", crate::localization::tr(locale, mid)) } else { - tool_display_label_for_name(name) + crate::localization::tr(locale, mid).to_string() } } @@ -237,6 +256,7 @@ mod tests { tool_display_label_for_name, tool_family_for_name, tool_family_for_title, tool_header_summary_for_name, }; + use crate::localization::{Locale, MessageId, tr}; #[test] fn legacy_titles_route_to_expected_families() { @@ -275,10 +295,16 @@ mod tests { "future_private_tool" ); - assert_eq!(tool_activity_label_for_name("exec_shell"), "run"); - assert_eq!(tool_activity_label_for_name("run_verifiers"), "verify"); assert_eq!( - tool_activity_label_for_name("future_private_tool"), + tool_activity_label_for_name("exec_shell", Locale::En), + "run" + ); + assert_eq!( + tool_activity_label_for_name("run_verifiers", Locale::En), + "verify" + ); + assert_eq!( + tool_activity_label_for_name("future_private_tool", Locale::En), "tool future_private_tool" ); } @@ -344,4 +370,80 @@ mod tests { assert_eq!(rail_glyph(CardRail::Bottom), "\u{2570}"); assert!(rail_glyph(CardRail::Single).is_empty()); } + + #[test] + fn tool_family_labels_localized_no_english_leak() { + let checks: &[(MessageId, &str, &str)] = &[ + (MessageId::ToolFamilyRead, "read", "đọc,读,読,读取,ler,leer"), + ( + MessageId::ToolFamilyPatch, + "patch", + "vá,補,パ,修补,corrigir,parchear", + ), + ( + MessageId::ToolFamilyRun, + "run", + "chạy,執,実,运行,executar,ejecutar", + ), + ( + MessageId::ToolFamilyFind, + "find", + "tìm,搜,検,搜索,buscar,buscar", + ), + ( + MessageId::ToolFamilyVerify, + "verify", + "xác minh,驗,検,验,verificar,verificar", + ), + ]; + for locale in [ + Locale::Ja, + Locale::ZhHans, + Locale::ZhHant, + Locale::PtBr, + Locale::Es419, + Locale::Vi, + ] { + for (id, eng, _) in checks { + let msg = tr(locale, *id); + assert!( + !msg.eq_ignore_ascii_case(eng), + "{} leaked exact English '{}' for '{:?}': {msg}", + locale.tag(), + eng, + id + ); + } + } + } + + #[test] + fn tool_family_activity_label_localized_no_english_leak() { + let known = [ + "exec_shell", + "read_file", + "apply_patch", + "grep_files", + "run_verifiers", + ]; + let english_labels = ["run", "read", "patch", "find", "verify"]; + for locale in [ + Locale::Ja, + Locale::ZhHans, + Locale::ZhHant, + Locale::PtBr, + Locale::Es419, + Locale::Vi, + ] { + for (tool, eng) in known.iter().zip(english_labels.iter()) { + let label = tool_activity_label_for_name(tool, locale); + assert!( + !label.eq_ignore_ascii_case(eng), + "{} leaked English '{}' for tool '{tool}': {label}", + locale.tag(), + eng, + ); + } + } + } } From b63287e6532c7d1ce99c8085fdcc862288d340ac Mon Sep 17 00:00:00 2001 From: cyq <61975706+cyq1017@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:53:29 +0800 Subject: [PATCH 13/25] feat(config): implement verbosity settings with normal and concise modes (#3052) * feat(config): implement verbosity settings with normal and concise modes * fix(config): wrap unsafe env calls in tests and fix clippy/fmt errors for CI * perf(config): avoid verbosity prompt allocations --- crates/cli/src/lib.rs | 26 +++++++++++ crates/config/src/lib.rs | 54 +++++++++++++++++++++++ crates/tui/src/config.rs | 20 ++++++++- crates/tui/src/core/engine.rs | 9 ++++ crates/tui/src/core/ops.rs | 1 + crates/tui/src/main.rs | 2 + crates/tui/src/prompts.rs | 63 +++++++++++++++++++++++++++ crates/tui/src/prompts/modes/agent.md | 2 + crates/tui/src/prompts/modes/plan.md | 2 + crates/tui/src/prompts/modes/yolo.md | 2 + crates/tui/src/runtime_threads.rs | 2 + crates/tui/src/tui/app.rs | 2 + crates/tui/src/tui/ui.rs | 3 ++ 13 files changed, 187 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 2482f0ef..5292d2fc 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -92,6 +92,12 @@ struct Cli { model: Option, #[arg(long = "output-mode")] output_mode: Option, + #[arg( + long = "verbosity", + value_name = "LEVEL", + help = "Controls transcript and output verbosity (normal, concise)" + )] + verbosity: Option, #[arg(long = "log-level")] log_level: Option, #[arg(long)] @@ -517,6 +523,7 @@ fn run() -> Result<()> { approval_policy: cli.approval_policy.clone(), sandbox_mode: cli.sandbox_mode.clone(), yolo: Some(cli.yolo), + verbosity: cli.verbosity.clone(), }; let command = cli.command.take(); @@ -1674,6 +1681,14 @@ fn build_tui_command( passthrough: Vec, ) -> Result { let tui = locate_sibling_tui_binary()?; + let mut verbosity = resolved_runtime.verbosity.clone(); + if verbosity.is_none() + && passthrough + .iter() + .any(|arg| matches!(arg.as_str(), "exec" | "swebench" | "eval")) + { + verbosity = Some("concise".to_string()); + } let mut cmd = Command::new(&tui); if let Some(config) = cli.config.as_ref() { @@ -1753,6 +1768,9 @@ fn build_tui_command( if let Some(output_mode) = cli.output_mode.as_ref() { cmd.env("DEEPSEEK_OUTPUT_MODE", output_mode); } + if let Some(v) = verbosity.as_ref() { + cmd.env("DEEPSEEK_VERBOSITY", v); + } if let Some(log_level) = cli.log_level.as_ref() { cmd.env("DEEPSEEK_LOG_LEVEL", log_level); } @@ -3124,6 +3142,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; @@ -3182,6 +3201,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; @@ -3221,6 +3241,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; @@ -3260,6 +3281,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: resolved_headers, }; @@ -3316,6 +3338,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; @@ -3381,6 +3404,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; @@ -3447,6 +3471,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; @@ -3543,6 +3568,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2acc34e7..aad7432b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -565,6 +565,7 @@ pub struct ConfigToml { pub model: Option, pub auth_mode: Option, pub output_mode: Option, + pub verbosity: Option, pub log_level: Option, pub telemetry: Option, pub approval_policy: Option, @@ -1077,6 +1078,9 @@ impl ConfigToml { if project.output_mode.is_some() { self.output_mode = project.output_mode; } + if project.verbosity.is_some() { + self.verbosity = project.verbosity; + } if project.log_level.is_some() { self.log_level = project.log_level; } @@ -1147,6 +1151,7 @@ impl ConfigToml { "model" => self.model.clone(), "auth.mode" => self.auth_mode.clone(), "output_mode" => self.output_mode.clone(), + "verbosity" => self.verbosity.clone(), "log_level" => self.log_level.clone(), "telemetry" => self.telemetry.map(|v| v.to_string()), "approval_policy" => self.approval_policy.clone(), @@ -1295,6 +1300,7 @@ impl ConfigToml { "model" => self.model = Some(value.to_string()), "auth.mode" => self.auth_mode = Some(value.to_string()), "output_mode" => self.output_mode = Some(value.to_string()), + "verbosity" => self.verbosity = Some(value.to_string()), "log_level" => self.log_level = Some(value.to_string()), "telemetry" => { self.telemetry = Some(parse_bool(value)?); @@ -1548,6 +1554,7 @@ impl ConfigToml { "model" => self.model = None, "auth.mode" => self.auth_mode = None, "output_mode" => self.output_mode = None, + "verbosity" => self.verbosity = None, "log_level" => self.log_level = None, "telemetry" => self.telemetry = None, "approval_policy" => self.approval_policy = None, @@ -1686,6 +1693,9 @@ impl ConfigToml { if let Some(v) = self.output_mode.as_ref() { out.insert("output_mode".to_string(), v.clone()); } + if let Some(v) = self.verbosity.as_ref() { + out.insert("verbosity".to_string(), v.clone()); + } if let Some(v) = self.log_level.as_ref() { out.insert("log_level".to_string(), v.clone()); } @@ -2150,6 +2160,11 @@ impl ConfigToml { .or_else(|| env.sandbox_mode.clone()) .or_else(|| self.sandbox_mode.clone()); let yolo = cli.yolo.or(env.yolo); + let verbosity = cli + .verbosity + .clone() + .or_else(|| env.verbosity.clone()) + .or_else(|| self.verbosity.clone()); ResolvedRuntimeOptions { provider, @@ -2165,6 +2180,7 @@ impl ConfigToml { approval_policy, sandbox_mode, yolo, + verbosity, http_headers, } } @@ -2770,6 +2786,7 @@ pub struct CliRuntimeOverrides { pub approval_policy: Option, pub sandbox_mode: Option, pub yolo: Option, + pub verbosity: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2807,6 +2824,7 @@ pub struct ResolvedRuntimeOptions { pub approval_policy: Option, pub sandbox_mode: Option, pub yolo: Option, + pub verbosity: Option, pub http_headers: BTreeMap, } @@ -3237,6 +3255,7 @@ struct EnvRuntimeOverrides { approval_policy: Option, sandbox_mode: Option, yolo: Option, + verbosity: Option, http_headers: Option>, deepseek_base_url: Option, nvidia_base_url: Option, @@ -3311,6 +3330,9 @@ impl EnvRuntimeOverrides { arcee_model: std::env::var("ARCEE_MODEL") .ok() .filter(|v| !v.trim().is_empty()), + verbosity: std::env::var("CODEWHALE_VERBOSITY") + .or_else(|_| std::env::var("DEEPSEEK_VERBOSITY")) + .ok(), output_mode: std::env::var("DEEPSEEK_OUTPUT_MODE").ok(), auth_mode: std::env::var("DEEPSEEK_AUTH_MODE").ok(), log_level: std::env::var("DEEPSEEK_LOG_LEVEL").ok(), @@ -6761,4 +6783,36 @@ unknown_policy = "surprise" assert!(err.to_string().contains("unknown_policy")); } + + #[test] + fn test_verbosity_resolution() { + let _lock = env_lock(); + // Test TOML parsing + let toml_str = r#" + verbosity = "concise" + "#; + let config: ConfigToml = toml::from_str(toml_str).unwrap(); + assert_eq!(config.verbosity, Some("concise".to_string())); + + // Test Env overrides + let _env = EnvGuard::without_deepseek_runtime_overrides(); + unsafe { + std::env::set_var("CODEWHALE_VERBOSITY", "normal"); + } + let env_overrides = EnvRuntimeOverrides::load(); + assert_eq!(env_overrides.verbosity, Some("normal".to_string())); + unsafe { + std::env::remove_var("CODEWHALE_VERBOSITY"); + } + + // Test fallback to DEEPSEEK_VERBOSITY + unsafe { + std::env::set_var("DEEPSEEK_VERBOSITY", "concise"); + } + let env_overrides = EnvRuntimeOverrides::load(); + assert_eq!(env_overrides.verbosity, Some("concise".to_string())); + unsafe { + std::env::remove_var("DEEPSEEK_VERBOSITY"); + } + } } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 4a27908f..862be03a 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1638,6 +1638,7 @@ pub struct Config { pub approval_policy: Option, pub sandbox_mode: Option, pub yolo: Option, + pub verbosity: Option, /// External sandbox backend: `"none"` or `"opensandbox"`. /// When set, exec_shell routes commands through the backend's HTTP API /// instead of spawning a local process. @@ -2217,6 +2218,12 @@ impl Config { ); } } + if let Some(v) = self.verbosity.as_deref() { + let normalized = v.trim().to_ascii_lowercase(); + if !matches!(normalized.as_str(), "normal" | "concise") { + anyhow::bail!("Invalid verbosity '{v}': expected normal or concise."); + } + } if let Some(mode) = self.sandbox_mode.as_deref() { let normalized = mode.trim().to_ascii_lowercase(); if !matches!( @@ -4058,6 +4065,11 @@ fn apply_env_overrides(config: &mut Config) { if let Ok(value) = std::env::var("DEEPSEEK_YOLO") { config.yolo = Some(value == "1" || value.eq_ignore_ascii_case("true")); } + if let Ok(value) = + std::env::var("CODEWHALE_VERBOSITY").or_else(|_| std::env::var("DEEPSEEK_VERBOSITY")) + { + config.verbosity = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_BACKEND") { config.sandbox_backend = Some(value); } @@ -4660,6 +4672,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { allow_shell: override_cfg.allow_shell.or(base.allow_shell), prompt_suggestion: override_cfg.prompt_suggestion.or(base.prompt_suggestion), yolo: override_cfg.yolo.or(base.yolo), + verbosity: override_cfg.verbosity.or(base.verbosity), approval_policy: override_cfg.approval_policy.or(base.approval_policy), sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode), sandbox_backend: override_cfg.sandbox_backend.or(base.sandbox_backend), @@ -4790,7 +4803,12 @@ fn warn_on_misplaced_top_level_keys(raw: &str) -> Option { // Sections CodeWhale does not recognize but users nest settings under. const UNKNOWN_SECTIONS: &[&str] = &["general", "sandbox"]; // Keys that are only ever read from the top level of the config. - const TOP_LEVEL_KEYS: &[&str] = &["allow_shell", "sandbox_mode", "approval_policy"]; + const TOP_LEVEL_KEYS: &[&str] = &[ + "allow_shell", + "sandbox_mode", + "approval_policy", + "verbosity", + ]; let mut hits: Vec = Vec::new(); for section in UNKNOWN_SECTIONS { diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6135cda8..2fa5a2c8 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -274,6 +274,7 @@ pub struct EngineConfig { /// Whether user-visible transcript rendering shows thinking blocks. /// Prompt assembly uses this to avoid localizing hidden reasoning. pub show_thinking: bool, + pub verbosity: Option, /// Maximum number of assistant steps before stopping. pub max_steps: u32, /// Maximum number of concurrently active subagents. @@ -430,6 +431,7 @@ impl Default for EngineConfig { ), tools_always_load: HashSet::new(), prefer_bwrap: false, + verbosity: None, tools: None, } } @@ -700,6 +702,7 @@ impl Engine { translation_enabled: config.translation_enabled, model_id: &config.model, show_thinking: config.show_thinking, + verbosity: config.verbosity.as_deref(), }, ); let stable_prompt = Some(system_prompt); @@ -1104,6 +1107,7 @@ impl Engine { show_thinking, allowed_tools, hook_executor, + verbosity, } => { self.handle_send_message( content, @@ -1121,6 +1125,7 @@ impl Engine { show_thinking, allowed_tools, hook_executor, + verbosity, ) .await; } @@ -1378,6 +1383,7 @@ impl Engine { self.config.show_thinking, self.config.allowed_tools.clone(), self.config.hook_executor.clone(), + self.config.verbosity.clone(), ) .await; } @@ -1532,6 +1538,7 @@ impl Engine { show_thinking: bool, allowed_tools: Option>, hook_executor: Option>, + verbosity: Option, ) { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); @@ -1660,6 +1667,7 @@ impl Engine { self.config.trust_mode = trust_mode; self.config.translation_enabled = translation_enabled; self.config.show_thinking = show_thinking; + self.config.verbosity = verbosity; // Refresh stable prompt context. Current mode is carried by the // request-time runtime prompt projection. @@ -2448,6 +2456,7 @@ impl Engine { translation_enabled: self.config.translation_enabled, model_id: &self.config.model, show_thinking: self.config.show_thinking, + verbosity: self.config.verbosity.as_deref(), }, ); let mut stable_prompt = diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 4ad48c06..97b30d4f 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -41,6 +41,7 @@ pub enum Op { /// Hook executor for control-plane hooks. /// `ToolCallBefore` hooks may deny a tool call with exit code 2. hook_executor: Option>, + verbosity: Option, }, /// Execute a user-submitted composer shell command (`! `) without diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index c642e152..2e9cbf5c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -5903,6 +5903,7 @@ async fn run_exec_agent( search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()), tools_always_load: config.tools_always_load(), tools: config.tools.clone(), + verbosity: config.verbosity.clone(), }; let engine_handle = spawn_engine(engine_config, config); @@ -5969,6 +5970,7 @@ async fn run_exec_agent( .and_then(crate::tui::approval::ApprovalMode::from_config_value) .unwrap_or_default() }, + verbosity: config.verbosity.clone(), }) .await?; diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index d713724f..ddfcd133 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -38,6 +38,7 @@ pub struct PromptSessionContext<'a> { /// When false, the prompt should not spend localization pressure on /// `reasoning_content` the user will never see. pub show_thinking: bool, + pub verbosity: Option<&'a str>, } impl Default for PromptSessionContext<'_> { @@ -50,6 +51,7 @@ impl Default for PromptSessionContext<'_> { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, } } } @@ -92,6 +94,18 @@ so any English prose in your response will block their decision-making." ) } +fn concise_output_discipline_instruction() -> &'static str { + "\ +## Concise Output Discipline + +To minimize token usage and optimize speed: +- Output only direct, actionable code, technical steps, or final answers. +- Eliminate all conversational filler, fluff, introductions, transitions, or summarizing conclusions. +- Do NOT explain what you are about to do or what you have just completed. +- Do NOT provide conversational status updates before or after running tools. +- Keep explanations and comments extremely brief and technical, explaining only non-obvious reasoning." +} + fn translation_target_language_for_tag(locale_tag: &str) -> &'static str { let normalized = locale_tag.trim().to_ascii_lowercase(); if normalized.starts_with("ja") { @@ -1035,6 +1049,7 @@ pub fn system_prompt_for_mode_with_context_and_skills( translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) } @@ -1117,6 +1132,13 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval( ); } + if session_context.verbosity == Some("concise") { + full_prompt = format!( + "{full_prompt}\n\n{}", + concise_output_discipline_instruction() + ); + } + // 3. Skills block. #432: walks every candidate workspace // skills directory (`.agents/skills`, `skills`, // `.opencode/skills`, `.claude/skills`, `.cursor/skills`) plus global @@ -1909,6 +1931,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -1978,6 +2001,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2020,6 +2044,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: false, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2072,6 +2097,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2175,6 +2201,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2211,6 +2238,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2239,6 +2267,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2296,6 +2325,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2324,6 +2354,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2530,6 +2561,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -2564,6 +2596,7 @@ mod tests { translation_enabled: false, model_id: "codewhale", show_thinking: true, + verbosity: None, }, ) { SystemPrompt::Text(text) => text, @@ -3088,4 +3121,34 @@ mod tests { "instructions block must annotate its source path" ); } + + #[test] + fn verbosity_concise_appends_discipline_block() { + let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path(); + let prompt = match super::system_prompt_for_mode_with_context_skills_session_and_approval( + workspace, + None, + None, + None, + PromptSessionContext { + user_memory_block: None, + goal_objective: None, + project_context_pack_enabled: false, + locale_tag: "en", + translation_enabled: false, + model_id: "codewhale", + show_thinking: true, + verbosity: Some("concise"), + }, + ) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + + assert!( + prompt.contains("## Concise Output Discipline"), + "Concise Output Discipline should be appended" + ); + } } diff --git a/crates/tui/src/prompts/modes/agent.md b/crates/tui/src/prompts/modes/agent.md index 38ae028c..8b0f56de 100644 --- a/crates/tui/src/prompts/modes/agent.md +++ b/crates/tui/src/prompts/modes/agent.md @@ -29,3 +29,5 @@ Long sessions accumulate context. To stay fast: - Suggest `/compact` or Ctrl+L when context nears 60% during sustained work — the compaction relay preserves open blockers - Use `note` for decisions you'll need across compaction boundaries - A 3-turn session that fans out to sub-agents finishes faster AND stays responsive longer than a 15-turn sequential grind + +Do NOT explain, announce, or mention to the user that you are running in Agent mode or how the approval policy works. Act silently on this mode instruction. diff --git a/crates/tui/src/prompts/modes/plan.md b/crates/tui/src/prompts/modes/plan.md index 3e6e648b..c4c80740 100644 --- a/crates/tui/src/prompts/modes/plan.md +++ b/crates/tui/src/prompts/modes/plan.md @@ -15,3 +15,5 @@ can't change it. Shell and code execution are unavailable. Use this mode to build a thorough plan. Spawn read-only sub-agents for parallel investigation. After `update_plan` presents the plan, wait for the user's next action instead of continuing to tool around in Plan mode. + +Do NOT explain, announce, or mention to the user that you are running in Plan mode, or describe the transition. Act silently on this mode instruction. diff --git a/crates/tui/src/prompts/modes/yolo.md b/crates/tui/src/prompts/modes/yolo.md index 0e867fb5..28456b6d 100644 --- a/crates/tui/src/prompts/modes/yolo.md +++ b/crates/tui/src/prompts/modes/yolo.md @@ -9,3 +9,5 @@ Even with auto-approval, use `checklist_write` for work that has several concret visible and trackable in the sidebar. Keep simple commands and focused edits direct. For multi-step initiatives, keep `checklist_write` current. Add `update_plan` only when a high-level strategy would help and do not duplicate the checklist there. + +Do NOT announce or mention to the user that you are running in YOLO mode. Act silently on this mode instruction. diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 724c9d35..54399cc5 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -1708,6 +1708,7 @@ impl RuntimeThreadManager { } else { crate::tui::approval::ApprovalMode::Suggest }, + verbosity: self.config.verbosity.clone(), }) .await .map_err(|e| anyhow!("Failed to start turn: {e}"))?; @@ -2093,6 +2094,7 @@ impl RuntimeThreadManager { search_base_url: self.config.search.as_ref().and_then(|s| s.base_url.clone()), tools_always_load: self.config.tools_always_load(), tools: self.config.tools.clone(), + verbosity: self.config.verbosity.clone(), }; let engine = spawn_engine(engine_cfg, &self.config); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 920584cc..762517f9 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1438,6 +1438,7 @@ pub struct App { pub compact_threshold: usize, pub max_input_history: usize, pub allow_shell: bool, + pub verbosity: Option, pub max_subagents: usize, /// Per-SSE-chunk idle timeout for streamed turns, in seconds. pub stream_chunk_timeout_secs: u64, @@ -2203,6 +2204,7 @@ impl App { compact_threshold, max_input_history, allow_shell, + verbosity: config.verbosity.clone(), max_subagents, stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(), subagent_cache: Vec::new(), diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 350265b4..f699023d 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -873,6 +873,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { project_context_pack_enabled: config.project_context_pack_enabled(), translation_enabled: app.translation_enabled, show_thinking: app.show_thinking, + verbosity: app.verbosity.clone(), // Effectively unlimited. V4 has a 1M context window and the user // wants the model running until it's actually done. The previous cap // of 100 hit the ceiling on long multi-step plans (wide refactors, @@ -5516,6 +5517,7 @@ async fn dispatch_user_message( translation_enabled: app.translation_enabled, model_id: &app.model, show_thinking: app.show_thinking, + verbosity: app.verbosity.as_deref(), }, ), ); @@ -5618,6 +5620,7 @@ async fn dispatch_user_message( show_thinking: app.show_thinking, allowed_tools: app.active_allowed_tools.clone(), hook_executor: app.runtime_services.hook_executor.clone(), + verbosity: app.verbosity.clone(), }) .await { From 38519552fde78de6a8a1987ef6149aaac4cc93dd Mon Sep 17 00:00:00 2001 From: cyq <61975706+cyq1017@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:53:32 +0800 Subject: [PATCH 14/25] feat(cli): track provider source and customize unsupported TUI errors (#3011) --- crates/cli/src/lib.rs | 198 +++++++++++++++++++++++++++++---------- crates/config/src/lib.rs | 95 ++++++++++++++++++- 2 files changed, 243 insertions(+), 50 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 5292d2fc..146e25a6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -14,7 +14,8 @@ use codewhale_app_server::{ AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio, }; use codewhale_config::{ - CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource, + CliRuntimeOverrides, ConfigStore, ProviderKind, ProviderSource, ResolvedRuntimeOptions, + RuntimeApiKeySource, }; use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine}; use codewhale_mcp::{McpServerDefinition, run_stdio_server}; @@ -806,32 +807,6 @@ const PROVIDER_LIST: [ProviderKind; 20] = [ ProviderKind::OpenaiCodex, ]; -fn provider_is_supported_by_tui(provider: ProviderKind) -> bool { - matches!( - provider, - ProviderKind::Deepseek - | ProviderKind::NvidiaNim - | ProviderKind::Openai - | ProviderKind::Atlascloud - | ProviderKind::WanjieArk - | ProviderKind::Volcengine - | ProviderKind::Openrouter - | ProviderKind::XiaomiMimo - | ProviderKind::Novita - | ProviderKind::Fireworks - | ProviderKind::Siliconflow - | ProviderKind::SiliconflowCN - | ProviderKind::Arcee - | ProviderKind::Moonshot - | ProviderKind::Sglang - | ProviderKind::Vllm - | ProviderKind::Ollama - | ProviderKind::Huggingface - | ProviderKind::Together - | ProviderKind::OpenaiCodex - ) -} - #[cfg(test)] fn no_keyring_secrets() -> Secrets { Secrets::new(std::sync::Arc::new( @@ -1714,33 +1689,41 @@ fn build_tui_command( } cmd.args(passthrough); - if !provider_is_supported_by_tui(resolved_runtime.provider) { - let source_hint = if cli.provider.is_some() { - "set via --provider flag" - } else { - "resolved from config file or environment" - }; - bail!( - "The interactive TUI does not support provider '{}' ({}).\n\ - \n\ - Supported TUI providers: deepseek, openai, ollama, openrouter, nvidia-nim, \n\ - volcengine, siliconflow, moonshot, arcee, fireworks, novita, xiaomi-mimo,\n\ - huggingface, sglang, vllm, atlascloud, wanjie-ark, together, openai-codex.\n\ - \n\ - To fix:\n\ - - Set a supported provider in your config file (~/.codewhale/config.toml)\n\ - under [providers.] with an api_key, or\n\ - - Pass --provider on the command line, or\n\ - - Run `codewhale exec --provider \"your prompt\"` for a\n\ - one-shot non-interactive session with this provider.", - resolved_runtime.provider.as_str(), - source_hint, - ); + let mut resolved_runtime = resolved_runtime.clone(); + let mut fallback_provider = None; + if !resolved_runtime.provider.is_tui_capable() { + match resolved_runtime.provider_source { + ProviderSource::Cli => { + bail!( + "The interactive TUI supports {} providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", + ProviderKind::tui_supported_providers_msg(), + resolved_runtime.provider.as_str() + ); + } + ProviderSource::Env(var_name) => { + bail!( + "The interactive TUI supports {} providers. Unset `{}` (currently `{}`) or use `codewhale model ...` for provider registry inspection.", + ProviderKind::tui_supported_providers_msg(), + var_name, + resolved_runtime.provider.as_str() + ); + } + ProviderSource::Config => { + eprintln!( + "warning: Unsupported provider '{}' configured in config.toml. Falling back to default 'deepseek'.", + resolved_runtime.provider.as_str() + ); + resolved_runtime.provider = ProviderKind::Deepseek; + fallback_provider = Some(resolved_runtime.provider); + } + } } if let Some(provider) = cli.provider { let provider: ProviderKind = provider.into(); cmd.env("DEEPSEEK_PROVIDER", provider.as_str()); + } else if let Some(provider) = fallback_provider { + cmd.env("DEEPSEEK_PROVIDER", provider.as_str()); } if matches!( resolved_runtime.api_key_source, @@ -3130,6 +3113,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Openai, + provider_source: ProviderSource::Cli, model: "glm-5".to_string(), api_key: Some("resolved-openai-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3189,6 +3173,7 @@ mod tests { let cli = parse_ok(&["codewhale", "doctor"]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::OpenaiCodex, + provider_source: ProviderSource::Config, model: "gpt-5.5".to_string(), api_key: None, api_key_source: None, @@ -3229,6 +3214,7 @@ mod tests { let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::OpenaiCodex, + provider_source: ProviderSource::Cli, model: "gpt-5.5".to_string(), api_key: None, api_key_source: None, @@ -3269,6 +3255,7 @@ mod tests { resolved_headers.insert("X-From-Base".to_string(), "base".to_string()); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Deepseek, + provider_source: ProviderSource::Config, model: "deepseek-v4-pro".to_string(), api_key: Some("config-file-key".to_string()), api_key_source: Some(RuntimeApiKeySource::ConfigFile), @@ -3326,6 +3313,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Moonshot, + provider_source: ProviderSource::Cli, model: "kimi-k2.6".to_string(), api_key: Some("resolved-kimi-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3392,6 +3380,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Volcengine, + provider_source: ProviderSource::Cli, model: "DeepSeek-V4-Pro".to_string(), api_key: Some("resolved-ark-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3459,6 +3448,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider: ProviderKind::Openai, + provider_source: ProviderSource::Cli, model: "glm-5".to_string(), api_key: None, api_key_source: None, @@ -3556,6 +3546,7 @@ mod tests { ]); let resolved = ResolvedRuntimeOptions { provider, + provider_source: ProviderSource::Cli, model: "test-model".to_string(), api_key: Some("test-key".to_string()), api_key_source: Some(RuntimeApiKeySource::Keyring), @@ -3846,4 +3837,113 @@ mod tests { let resolved = locate_sibling_tui_binary().expect("override must resolve"); assert_eq!(resolved, custom); } + + #[test] + fn test_build_tui_command_unsupported_provider_cli() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek"]); + let options = ResolvedRuntimeOptions { + provider: ProviderKind::Anthropic, + provider_source: ProviderSource::Cli, + model: "model".to_string(), + api_key: None, + api_key_source: None, + base_url: "url".to_string(), + auth_mode: None, + insecure_skip_tls_verify: false, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + let err = build_tui_command(&cli, &options, vec![]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Remove --provider anthropic"), "msg: {}", msg); + assert!(msg.contains("The interactive TUI supports"), "msg: {}", msg); + } + + #[test] + fn test_build_tui_command_unsupported_provider_env() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek"]); + let options = ResolvedRuntimeOptions { + provider: ProviderKind::Anthropic, + provider_source: ProviderSource::Env("CODEWHALE_PROVIDER"), + model: "model".to_string(), + api_key: None, + api_key_source: None, + base_url: "url".to_string(), + auth_mode: None, + insecure_skip_tls_verify: false, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + let err = build_tui_command(&cli, &options, vec![]).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unset `CODEWHALE_PROVIDER` (currently `anthropic`)"), + "msg: {}", + msg + ); + } + + #[test] + fn test_build_tui_command_unsupported_provider_config_fallback() { + let _lock = env_lock(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let custom = dir + .path() + .join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX)); + std::fs::write(&custom, b"").unwrap(); + let custom_str = custom.to_string_lossy().into_owned(); + let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str); + + let cli = parse_ok(&["deepseek"]); + let options = ResolvedRuntimeOptions { + provider: ProviderKind::Anthropic, + provider_source: ProviderSource::Config, + model: "model".to_string(), + api_key: None, + api_key_source: None, + base_url: "url".to_string(), + auth_mode: None, + insecure_skip_tls_verify: false, + output_mode: None, + log_level: None, + telemetry: false, + approval_policy: None, + sandbox_mode: None, + yolo: None, + http_headers: std::collections::BTreeMap::new(), + }; + let cmd = build_tui_command(&cli, &options, vec![]).expect("should graceful fallback"); + assert_eq!( + command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(), + Some("deepseek") + ); + } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index aad7432b..be7da8b6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -159,6 +159,79 @@ pub enum ProviderKind { } impl ProviderKind { + #[must_use] + pub fn is_tui_capable(self) -> bool { + matches!( + self, + Self::Deepseek + | Self::NvidiaNim + | Self::Openai + | Self::Atlascloud + | Self::WanjieArk + | Self::Volcengine + | Self::Openrouter + | Self::XiaomiMimo + | Self::Novita + | Self::Fireworks + | Self::Siliconflow + | Self::SiliconflowCN + | Self::Arcee + | Self::Moonshot + | Self::Sglang + | Self::Vllm + | Self::Ollama + | Self::Huggingface + | Self::Together + | Self::OpenaiCodex + ) + } + + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::Deepseek => "DeepSeek", + Self::NvidiaNim => "NVIDIA NIM", + Self::Openai => "OpenAI-compatible", + Self::Atlascloud => "AtlasCloud", + Self::WanjieArk => "Wanjie Ark", + Self::Volcengine => "Volcengine Ark", + Self::Openrouter => "OpenRouter", + Self::XiaomiMimo => "Xiaomi MiMo", + Self::Novita => "Novita", + Self::Fireworks => "Fireworks", + Self::Siliconflow => "SiliconFlow", + Self::SiliconflowCN => "SiliconFlow (CN)", + Self::Arcee => "Arcee AI", + Self::Moonshot => "Moonshot/Kimi", + Self::Sglang => "SGLang", + Self::Vllm => "vLLM", + Self::Ollama => "Ollama", + Self::Huggingface => "Hugging Face", + Self::Together => "Together AI", + Self::OpenaiCodex => "OpenAI Codex", + Self::Anthropic => "Anthropic", + } + } + + #[must_use] + pub fn tui_supported_providers_msg() -> String { + let mut names = Vec::new(); + for p in Self::ALL { + if p.is_tui_capable() { + names.push(p.display_name()); + } + } + match names.as_slice() { + [] => String::new(), + [only] => (*only).to_string(), + [first, second] => format!("{first} and {second}"), + _ => { + let last = names.pop().expect("non-empty"); + format!("{}, and {}", names.join(", "), last) + } + } + } + pub const ALL: [Self; 21] = [ Self::Deepseek, Self::NvidiaNim, @@ -1968,7 +2041,18 @@ impl ConfigToml { secrets: &Secrets, ) -> ResolvedRuntimeOptions { let env = EnvRuntimeOverrides::load(); - let provider = cli.provider.or(env.provider).unwrap_or(self.provider); + let (provider, provider_source) = if let Some(p) = cli.provider { + (p, ProviderSource::Cli) + } else if let Some(p) = env.provider { + let var_name = if std::env::var("CODEWHALE_PROVIDER").is_ok() { + "CODEWHALE_PROVIDER" + } else { + "DEEPSEEK_PROVIDER" + }; + (p, ProviderSource::Env(var_name)) + } else { + (self.provider, ProviderSource::Config) + }; let mut provider_cfg = self.providers.for_provider(provider).clone(); if provider == ProviderKind::SiliconflowCN { @@ -2168,6 +2252,7 @@ impl ConfigToml { ResolvedRuntimeOptions { provider, + provider_source, model, api_key, api_key_source, @@ -2809,9 +2894,17 @@ impl RuntimeApiKeySource { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderSource { + Cli, + Env(&'static str), + Config, +} + #[derive(Debug, Clone)] pub struct ResolvedRuntimeOptions { pub provider: ProviderKind, + pub provider_source: ProviderSource, pub model: String, pub api_key: Option, pub api_key_source: Option, From 5d9b5f67cbdf97d916d4b75ad42c6367b2f2506e Mon Sep 17 00:00:00 2001 From: Hanmiao Li <894876246@qq.com> Date: Sat, 13 Jun 2026 01:53:36 +0800 Subject: [PATCH 15/25] feat(bench): improve cli-compare harness with real Harbor integration (#3009) - Match actual Harbor CLI interface (no invented flags) - Proper BaseInstalledAgent subclass for Codex - Robust token extraction from stream JSONL + transcript parsing - Heuristic answer_len extraction (## Final Answer markers) - Metadata capture: versions, git commit, platform, timestamp - --regenerate walks existing run directories - All missing fields explicit null, never zero - Support multiple runs per task with run_idx tracking The harness is designed to run: harbor run --dataset terminal-bench@2.0: --agent ... --model ... for both codex and codewhale agents, then normalize the results. --- benchmark_results/.gitkeep | 0 scripts/benchmarks/cli-compare.py | 580 +++++++++++++++++++++++ scripts/benchmarks/harbor/codex_agent.py | 126 +++++ 3 files changed, 706 insertions(+) create mode 100644 benchmark_results/.gitkeep create mode 100755 scripts/benchmarks/cli-compare.py create mode 100755 scripts/benchmarks/harbor/codex_agent.py diff --git a/benchmark_results/.gitkeep b/benchmark_results/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/scripts/benchmarks/cli-compare.py b/scripts/benchmarks/cli-compare.py new file mode 100755 index 00000000..48ae762f --- /dev/null +++ b/scripts/benchmarks/cli-compare.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +""" +cli-compare.py — Run Terminal-Bench tasks through CodeWhale and Codex CLIs, +emit normalized token/performance comparison rows. + +Usage: + # Run default tasks + python scripts/benchmarks/cli-compare.py + + # Specific task and model + python scripts/benchmarks/cli-compare.py --task prove-plus-comm \\ + --model deepseek/deepseek-chat --runs 3 + + # Regenerate from existing run artifacts + python scripts/benchmarks/cli-compare.py \\ + --regenerate benchmark_results/cli-compare-20260609 + +Output (per run date): + benchmark_results/cli-compare-YYYYMMDD/ + summary.json — one row per agent, all fields normalized + summary.md — Markdown table suitable for release notes + metadata.json — versions, model, timestamp, platform + codewhale// — raw Harbor output + codex// — raw Harbor output + +Prerequisites: + pip install harbor + Docker running + DEEPSEEK_API_KEY set (for CodeWhale) + CODEX_API_KEY or equivalent set (for Codex) + +Field semantics (summary.json rows): + task str — Terminal-Bench task name + agent str — "codewhale" or "codex" + run_idx int — 0-based run index + reward float — pass/fail score (1.0 = pass) + runtime_s float — wall-clock seconds (null if not available) + exception str — raised exception text (null = clean finish) + input_tokens int — provider-reported input tokens + cached_tokens int — provider-reported cached input tokens (null if N/A) + output_tokens int — provider-reported output tokens + reasoning_tokens int — provider-reported reasoning tokens (null if N/A) + answer_len int — locally-derived visible final-answer character count + transcript_path str — relative path to raw agent output file + +All missing metrics are serialized as JSON ``null`` — never silently zeroed. +""" + +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent.parent + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +DEFAULT_TASKS = [ + "prove-plus-comm", + "cancel-async-tasks", + "configure-git-webserver", + "fix-code-vulnerability", +] +DEFAULT_MODEL = "deepseek/deepseek-chat" +DEFAULT_TIMEOUT_PER_RUN = 900 # seconds (Harbor handles its own timeout internally) +DEFAULT_RUNS = 1 +HARBOR_DATASET = "terminal-bench@2.0" +CODEWHALE_AGENT = "scripts.benchmarks.harbor:CodeWhaleAgent" +CODEX_AGENT = "scripts.benchmarks.harbor.codex_agent:CodexAgent" + +# --------------------------------------------------------------------------- +# Harbor integration +# --------------------------------------------------------------------------- + + +def check_harbor() -> None: + """Verify Harbor is installed and Docker is running.""" + if subprocess.run(["which", "harbor"], capture_output=True).returncode != 0: + sys.exit("Error: 'harbor' not found. Install with: pip install harbor") + if subprocess.run(["docker", "info"], capture_output=True).returncode != 0: + sys.exit("Error: Docker not running. Harbor requires Docker.") + + +def run_harbor_single_task( + task: str, + model: str, + agent_path: str, + results_dir: Path, + timeout: int, +) -> dict[str, Any]: + """Run a single Terminal-Bench task through Harbor. + + Harbor supports task-level filtering via the ``--task`` flag (Harbor ≥0.4). + If unavailable, falls back to running the full dataset with env-based filtering. + """ + dataset = f"{HARBOR_DATASET}:{task}" # Harbor colon-syntax for single task + results_dir.mkdir(parents=True, exist_ok=True) + + cmd = [ + "harbor", "run", + "--dataset", dataset, + "--agent", agent_path, + "--model", model, + "--n-concurrent", "1", + "--results-dir", str(results_dir), + ] + + start = time.time() + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + cwd=REPO_ROOT, + ) + runtime_s = round(time.time() - start, 2) + except subprocess.TimeoutExpired: + runtime_s = round(time.time() - start, 2) + return { + "task": task, "model": model, "agent": agent_path, + "runtime_s": runtime_s, "exit_code": -1, + "exception": f"Timeout after {timeout}s", + "stdout": "", "stderr": "", "results_dir": str(results_dir), + } + + return { + "task": task, "model": model, "agent": agent_path, + "runtime_s": runtime_s, + "exit_code": proc.returncode, + "exception": None, + "stdout": proc.stdout, + "stderr": proc.stderr, + "results_dir": str(results_dir), + } + + +# --------------------------------------------------------------------------- +# Result parsing +# --------------------------------------------------------------------------- + + +def _try_int(val: Any) -> Optional[int]: + if val is None: + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + +def _try_float(val: Any) -> Optional[float]: + if val is None: + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + +def parse_token_jsonl(lines: list[str]) -> dict[str, Optional[int]]: + """Extract token usage from CodeWhale/Codex stream JSONL lines. + + CodeWhale emits ``{"type":"result","usage":{...}}`` at end-of-stream. + Codex may emit usage in closing messages or transcript footers. + """ + result: dict[str, Optional[int]] = { + "input_tokens": None, "cached_tokens": None, + "output_tokens": None, "reasoning_tokens": None, + } + if not lines: + return result + + for line in reversed(lines): # usage typically at the end + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + # Try regex extraction for non-JSON transcript lines + continue + + usage = obj.get("usage") or obj.get("token_usage") or {} + if isinstance(usage, dict): + if result["input_tokens"] is None: + result["input_tokens"] = _try_int( + usage.get("input_tokens") or usage.get("prompt_tokens") + ) + if result["cached_tokens"] is None: + result["cached_tokens"] = _try_int( + usage.get("cached_input_tokens") + or usage.get("cache_read_input_tokens") + or usage.get("cached_tokens") + ) + if result["output_tokens"] is None: + result["output_tokens"] = _try_int( + usage.get("output_tokens") or usage.get("completion_tokens") + ) + if result["reasoning_tokens"] is None: + result["reasoning_tokens"] = _try_int( + usage.get("reasoning_tokens") + or usage.get("thinking_tokens") + or usage.get("reasoning_completion_tokens") + ) + if all(v is not None for v in result.values()): + break + + return result + + +def extract_answer_len(text: str) -> Optional[int]: + """Heuristic: length of the last substantial text block that looks like an answer. + + Looks for the last non-code, non-log paragraph after the agent has finished + its tool-calling phase. Returns character count or None. + """ + if not text: + return None + # Agent outputs often have a "## Final Answer" or similar marker. + # Try to find the last answer section. + for marker in ("## Final Answer", "## Answer", "final answer", + "Here is the", "The solution"): + idx = text.rfind(marker) + if idx >= 0: + # Take text from marker to end, strip trailing shell logs + tail = text[idx:] + # Stop at next shell prompt or markdown separator + for term in ("```", "$ ", "# ", "/workspace"): + term_idx = tail.find(term, len(marker)) + if term_idx > 0: + tail = tail[:term_idx] + return len(tail.strip()) + + # Fallback: last paragraph that isn't code or a prompt + paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] + for p in reversed(paragraphs): + if not p.startswith("```") and not p.startswith("$") and len(p) > 20: + return len(p) + + return len(text.strip()) if text.strip() else None + + +def parse_harbor_run(task_dir: Path, agent_name: str) -> dict[str, Any]: + """Parse Harbor results for a single task run. + + Harbor stores per-task output in: + / + results.json — Harbor's own eval summary + logs/agent/*.txt — raw agent transcript (if stdout captured) + """ + row: dict[str, Any] = { + "task": task_dir.name, + "agent": agent_name, + "reward": None, + "runtime_s": None, + "exception": None, + "input_tokens": None, + "cached_tokens": None, + "output_tokens": None, + "reasoning_tokens": None, + "answer_len": None, + "transcript_path": None, + } + + # 1. Harbor results.json — pass/fail and runtime + for candidate in sorted(task_dir.rglob("results.json")): + try: + data = json.loads(candidate.read_text()) + if isinstance(data, dict): + row["reward"] = _try_float(data.get("score") or data.get("reward")) + row["runtime_s"] = _try_float(data.get("runtime") or data.get("duration")) + exc = data.get("exception") or data.get("error") + row["exception"] = str(exc) if exc else None + break + except (json.JSONDecodeError, OSError): + continue + + # 2. Agent transcript — token usage and answer + for txt_file in sorted(task_dir.rglob("*.txt")): + if txt_file.name.startswith("."): + continue + try: + text = txt_file.read_text(errors="ignore") + except OSError: + continue + if not text.strip(): + continue + + row["transcript_path"] = str(txt_file.relative_to(REPO_ROOT)) + + # Token extraction from stream JSONL + tokens = parse_token_jsonl(text.split("\n")) + row["input_tokens"] = row["input_tokens"] or tokens["input_tokens"] + row["cached_tokens"] = row["cached_tokens"] or tokens["cached_tokens"] + row["output_tokens"] = row["output_tokens"] or tokens["output_tokens"] + row["reasoning_tokens"] = row["reasoning_tokens"] or tokens["reasoning_tokens"] + + # Answer length + if row["answer_len"] is None: + row["answer_len"] = extract_answer_len(text) + break + + # 3. Harbor run metadata — runtime fallback + for meta_file in sorted(task_dir.rglob("run_metadata.json")): + try: + data = json.loads(meta_file.read_text()) + if isinstance(data, dict) and row["runtime_s"] is None: + row["runtime_s"] = _try_float(data.get("runtime_seconds")) + except (json.JSONDecodeError, OSError): + continue + + return row + + +# --------------------------------------------------------------------------- +# Summary generation +# --------------------------------------------------------------------------- + + +def generate_markdown_table(rows: list[dict[str, Any]]) -> str: + """Generate a Markdown comparison table from normalized rows.""" + if not rows: + return "*(no data)*\n" + + headers = [ + "task", "agent", "reward", "input_tokens", "cached_tokens", + "output_tokens", "reasoning_tokens", "runtime_s", "answer_len", + ] + + md = "| " + " | ".join(h.replace("_", " ") for h in headers) + " |\n" + md += "|" + "|".join(" ---: " for _ in headers) + "|\n" + + for row in rows: + cells: list[str] = [] + for h in headers: + val = row.get(h) + if val is None: + cells.append("null") + elif isinstance(val, float): + cells.append(f"{val:.2f}") + elif isinstance(val, int): + cells.append(f"{val:,}") + else: + cells.append(str(val)) + md += "| " + " | ".join(cells) + " |\n" + + return md + + +def generate_json_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return rows sorted by task, agent, run_idx.""" + return sorted( + rows, + key=lambda r: (r.get("task", ""), r.get("agent", ""), r.get("run_idx", 0)), + ) + + +# --------------------------------------------------------------------------- +# Regenerate from existing logs +# --------------------------------------------------------------------------- + + +def regenerate(results_dir: Path) -> list[dict[str, Any]]: + """Walk existing run directory and rebuild normalized rows.""" + rows: list[dict[str, Any]] = [] + for agent_dir in sorted(results_dir.iterdir()): + if not agent_dir.is_dir() or agent_dir.name.startswith("."): + continue + agent_name = agent_dir.name + for task_dir in sorted(agent_dir.iterdir()): + if not task_dir.is_dir(): + continue + # Check for per-run subdirectories + subdirs = [d for d in task_dir.iterdir() if d.is_dir()] + if subdirs and all(d.name.startswith("run_") for d in subdirs): + for run_dir in sorted(subdirs): + row = parse_harbor_run(run_dir, agent_name) + row["task"] = row["task"] or task_dir.name + try: + row["run_idx"] = int(run_dir.name.split("_")[-1]) + except (ValueError, IndexError): + row["run_idx"] = 0 + rows.append(row) + else: + row = parse_harbor_run(task_dir, agent_name) + row["task"] = row["task"] or task_dir.name + row["run_idx"] = 0 + rows.append(row) + return rows + + +# --------------------------------------------------------------------------- +# Metadata capture +# --------------------------------------------------------------------------- + + +def capture_metadata(model: str) -> dict[str, Any]: + """Capture environment metadata for reproducibility.""" + meta: dict[str, Any] = { + "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "platform": os.uname().sysname + "/" + os.uname().machine, + "model": model, + "dataset": HARBOR_DATASET, + } + # CodeWhale version + r = subprocess.run(["codewhale", "--version"], capture_output=True, text=True) + if r.returncode == 0: + meta["codewhale_version"] = r.stdout.strip() + # Codex version + r = subprocess.run(["codex", "--version"], capture_output=True, text=True) + if r.returncode == 0: + meta["codex_version"] = r.stdout.strip() + # Harbor version + r = subprocess.run(["harbor", "--version"], capture_output=True, text=True) + if r.returncode == 0: + meta["harbor_version"] = r.stdout.strip() + # Git commit + r = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, text=True, cwd=REPO_ROOT, + ) + if r.returncode == 0: + meta["git_commit"] = r.stdout.strip()[:12] + return meta + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser( + description="CodeWhale vs Codex CLI token comparison harness", + ) + parser.add_argument( + "--task", nargs="+", default=DEFAULT_TASKS, + help=f"Terminal-Bench task names (default: {' '.join(DEFAULT_TASKS)})", + ) + parser.add_argument( + "--model", default=DEFAULT_MODEL, + help=f"Model in provider/name format (default: {DEFAULT_MODEL})", + ) + parser.add_argument( + "--runs", type=int, default=DEFAULT_RUNS, + help=f"Number of runs per agent per task (default: {DEFAULT_RUNS})", + ) + parser.add_argument( + "--timeout", type=int, default=DEFAULT_TIMEOUT_PER_RUN, + help=f"Timeout per run in seconds (default: {DEFAULT_TIMEOUT_PER_RUN})", + ) + parser.add_argument( + "--regenerate", type=Path, default=None, + help="Regenerate summary from existing raw results directory", + ) + parser.add_argument( + "--codewhale-agent", default=CODEWHALE_AGENT, + help="Harbor agent import path for CodeWhale", + ) + parser.add_argument( + "--codex-agent", default=CODEX_AGENT, + help="Harbor agent import path for Codex", + ) + args = parser.parse_args() + + # --------------- Regenerate mode --------------- + if args.regenerate: + results_dir = args.regenerate + if not results_dir.exists(): + sys.exit(f"Error: results directory not found: {results_dir}") + rows = regenerate(results_dir) + print(generate_markdown_table(rows)) + return + + # --------------- Fresh run mode --------------- + check_harbor() + + date_str = datetime.now().strftime("%Y%m%d") + run_dir = REPO_ROOT / "benchmark_results" / f"cli-compare-{date_str}" + if run_dir.exists(): + # Append run number if directory already exists + suffix = 2 + while (run_dir := REPO_ROOT / "benchmark_results" / + f"cli-compare-{date_str}-{suffix}").exists(): + suffix += 1 + run_dir.mkdir(parents=True, exist_ok=True) + + # Metadata + meta = capture_metadata(args.model) + meta["tasks"] = args.task + meta["runs_per_task"] = args.runs + (run_dir / "metadata.json").write_text(json.dumps(meta, indent=2)) + + cw_dir = run_dir / "codewhale" + cx_dir = run_dir / "codex" + cw_dir.mkdir(parents=True, exist_ok=True) + cx_dir.mkdir(parents=True, exist_ok=True) + + all_rows: list[dict[str, Any]] = [] + + for task in args.task: + for run_idx in range(args.runs): + header = f"Task: {task} Run: {run_idx+1}/{args.runs}" + print(f"\n{'='*60}") + print(header) + print("=" * 60) + + # --- CodeWhale --- + print("\n--- CodeWhale ---") + cw_run_dir = cw_dir / task / f"run_{run_idx}" + cw_result = run_harbor_single_task( + task=task, model=args.model, + agent_path=args.codewhale_agent, + results_dir=cw_run_dir, timeout=args.timeout, + ) + cw_row = parse_harbor_run(cw_run_dir, "codewhale") + cw_row["task"] = task + cw_row["run_idx"] = run_idx + cw_row["runtime_s"] = cw_row["runtime_s"] or cw_result["runtime_s"] + if cw_result["exception"]: + cw_row["exception"] = cw_row["exception"] or cw_result["exception"] + all_rows.append(cw_row) + self_report(cw_row) + + # --- Codex --- + print("\n--- Codex ---") + cx_run_dir = cx_dir / task / f"run_{run_idx}" + cx_result = run_harbor_single_task( + task=task, model=args.model, + agent_path=args.codex_agent, + results_dir=cx_run_dir, timeout=args.timeout, + ) + cx_row = parse_harbor_run(cx_run_dir, "codex") + cx_row["task"] = task + cx_row["run_idx"] = run_idx + cx_row["runtime_s"] = cx_row["runtime_s"] or cx_result["runtime_s"] + if cx_result["exception"]: + cx_row["exception"] = cx_row["exception"] or cx_result["exception"] + all_rows.append(cx_row) + self_report(cx_row) + + # Write summaries + summary_json = run_dir / "summary.json" + summary_json.write_text( + json.dumps(generate_json_summary(all_rows), indent=2) + ) + print(f"\nSummary JSON: {summary_json}") + + md = generate_markdown_table(all_rows) + (run_dir / "summary.md").write_text(md) + print(f"Summary MD: {run_dir / 'summary.md'}") + print(f"Metadata: {run_dir / 'metadata.json'}") + print("\n" + md) + + +def self_report(row: dict[str, Any]) -> None: + """Print a one-line summary of a parsed run.""" + parts = [ + f"reward={row['reward']}" if row["reward"] is not None else "reward=null", + f"input={row['input_tokens']}" if row["input_tokens"] is not None else "input=null", + f"output={row['output_tokens']}" if row["output_tokens"] is not None else "output=null", + f"cached={row['cached_tokens']}" if row["cached_tokens"] is not None else "", + f"reasoning={row['reasoning_tokens']}" if row["reasoning_tokens"] is not None else "", + f"answer_len={row['answer_len']}" if row["answer_len"] is not None else "", + f"runtime={row['runtime_s']:.1f}s" if row["runtime_s"] is not None else "", + ] + print(" " + ", ".join(p for p in parts if p)) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks/harbor/codex_agent.py b/scripts/benchmarks/harbor/codex_agent.py new file mode 100755 index 00000000..abae2104 --- /dev/null +++ b/scripts/benchmarks/harbor/codex_agent.py @@ -0,0 +1,126 @@ +"""Harbor adapter for Codex CLI.""" + +import json +import os +import shlex +from pathlib import Path, PurePosixPath +from typing import Any + +from harbor.agents.installed.base import ( + BaseInstalledAgent, + CliFlag, + with_prompt_template, +) +from harbor.environments.base import BaseEnvironment +from harbor.models.agent.context import AgentContext + + +class CodexAgent(BaseInstalledAgent): + """Codex CLI agent adapter for Harbor.""" + + _OUTPUT_FILENAME = "codex.txt" + + CLI_FLAGS = [ + CliFlag( + "allowed-tools", + cli="--allowed-tools", + type="str", + default="Bash,Read,Write,Edit,Glob,Grep", + ), + ] + + @staticmethod + def name() -> str: + return "codex" + + def version(self) -> str | None: + return getattr(self, "_version", None) + + def get_version_command(self) -> str | None: + return "codex --version 2>/dev/null || codex-cli --version 2>/dev/null" + + def parse_version(self, stdout: str) -> str: + text = stdout.strip() + for line in text.splitlines(): + line = line.strip() + if line: + for prefix in ("codex-cli ", "codex "): + if line.lower().startswith(prefix): + return line[len(prefix):] + return line + return text + + async def install(self, environment: BaseEnvironment) -> None: + """Install Codex CLI in the container.""" + await self.exec_as_root( + environment, + command=( + "if ldd --version 2>&1 | grep -qi musl || [ -f /etc/alpine-release ]; then" + " apk add --no-cache curl bash nodejs npm git ripgrep;" + " elif command -v apt-get &>/dev/null; then" + " apt-get update && apt-get install -y curl git ripgrep;" + " elif command -v yum &>/dev/null; then" + " yum install -y curl git ripgrep;" + " fi" + ), + env={"DEBIAN_FRONTEND": "noninteractive"}, + ) + + await self.exec_as_root( + environment, + command=( + "if ! command -v node &>/dev/null; then" + " curl -fsSL https://deb.nodesource.com/setup_20.x | bash - &&" + " apt-get install -y nodejs;" + " fi" + ), + env={"DEBIAN_FRONTEND": "noninteractive"}, + ) + + await self.exec_as_agent( + environment, + command="npm install -g codex", + ) + + @with_prompt_template + async def run( + self, + instruction: str, + environment: BaseEnvironment, + context: AgentContext, + ) -> None: + """Run Codex CLI in non-interactive exec mode.""" + escaped_instruction = shlex.quote(instruction) + + cli_flags = self.build_cli_flags() + extra_flags = (cli_flags + " ") if cli_flags else "" + + model_flag = "" + if self.model_name: + model_flag = f"--model {shlex.quote(self.model_name)} " + + # Forward API keys + env: dict[str, str] = {} + for key in ("CODEX_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"): + val = os.environ.get(key, "") + if val: + env[key] = val + + output_path = f"/logs/agent/{self._OUTPUT_FILENAME}" + + await self.exec_as_agent( + environment, + command=( + f"codex exec --yes " + f"{model_flag}{extra_flags}" + f"--workspace /workspace " + f"{escaped_instruction} " + f"2>&1 | tee {shlex.quote(output_path)}" + f" || true" + ), + env=env if env else None, + ) + + def populate_context_post_run(self, context: AgentContext) -> None: + pass From 4e3e12eee74d01a8aadb4966d38e7d395cd768c1 Mon Sep 17 00:00:00 2001 From: Turisla Date: Sat, 13 Jun 2026 01:53:39 +0800 Subject: [PATCH 16/25] feat(execpolicy): expose matched approval rule metadata (#2971) --- crates/core/src/lib.rs | 51 +++++++++++++++++++++++++++++++------- crates/hooks/src/lib.rs | 17 ++++++++++++- crates/protocol/src/lib.rs | 3 +++ docs/RUNTIME_API.md | 4 +++ 4 files changed, 65 insertions(+), 10 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 900e16b2..77455e2b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1137,7 +1137,7 @@ impl Runtime { .await; self.hooks .emit(HookEvent::GenericEventFrame { - frame: error_frame.clone(), + frame: Box::new(error_frame.clone()), }) .await; return Ok(json!({ @@ -1156,6 +1156,7 @@ impl Runtime { let reason = decision.reason().to_string(); let maybe_approval_frame = approval_request_frame( &decision.requirement, + decision.matched_rule.as_deref(), call_id, approval_id.clone(), response_id.clone(), @@ -1173,7 +1174,7 @@ impl Runtime { if let Some(frame) = maybe_approval_frame { self.hooks .emit(HookEvent::GenericEventFrame { - frame: frame.clone(), + frame: Box::new(frame.clone()), }) .await; events.push(event_frame_payload(&frame)); @@ -1197,7 +1198,7 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: start_frame.clone(), + frame: Box::new(start_frame.clone()), }) .await; self.hooks @@ -1221,7 +1222,7 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: result_frame.clone(), + frame: Box::new(result_frame.clone()), }) .await; self.hooks @@ -1253,7 +1254,7 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: error_frame.clone(), + frame: Box::new(error_frame.clone()), }) .await; self.hooks @@ -1299,18 +1300,18 @@ impl Runtime { }; self.hooks .emit(HookEvent::GenericEventFrame { - frame: EventFrame::McpStartupUpdate { + frame: Box::new(EventFrame::McpStartupUpdate { update: codewhale_protocol::McpStartupUpdateEvent { server_name: update.server_name, status, }, - }, + }), }) .await; } self.hooks .emit(HookEvent::GenericEventFrame { - frame: EventFrame::McpStartupComplete { + frame: Box::new(EventFrame::McpStartupComplete { summary: codewhale_protocol::McpStartupCompleteEvent { ready: summary.ready.clone(), failed: summary @@ -1323,7 +1324,7 @@ impl Runtime { .collect(), cancelled: summary.cancelled.clone(), }, - }, + }), }) .await; summary @@ -1578,6 +1579,7 @@ fn to_persisted_source(source: &codewhale_protocol::SessionSource) -> SessionSou fn approval_request_frame( requirement: &ExecApprovalRequirement, + matched_rule: Option<&str>, call_id: String, approval_id: String, turn_id: String, @@ -1620,6 +1622,7 @@ fn approval_request_frame( command, cwd, reason: reason.clone(), + matched_rule: matched_rule.map(|rule| rule.to_string().into_boxed_str()), network_approval_context: None, proposed_execpolicy_amendment: proposed_execpolicy_amendment .as_ref() @@ -1886,6 +1889,36 @@ mod tests { assert_eq!(permission_path_for_call(&call), None); } + #[test] + fn approval_request_frame_includes_matched_rule() { + let requirement = ExecApprovalRequirement::NeedsApproval { + reason: "Typed ask rule 'tool=exec_shell command=cargo test' requires approval." + .to_string(), + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: Vec::new(), + }; + + let frame = approval_request_frame( + &requirement, + Some("tool=exec_shell command=cargo test"), + "call-1".to_string(), + "approval-1".to_string(), + "turn-1".to_string(), + "cargo test --workspace".to_string(), + "/repo".to_string(), + ) + .expect("approval frame"); + + let EventFrame::ExecApprovalRequest { request } = frame else { + panic!("expected exec approval request frame"); + }; + assert_eq!( + request.matched_rule.as_deref(), + Some("tool=exec_shell command=cargo test") + ); + assert_eq!(request.reason, requirement.reason()); + } + #[test] fn enqueue_creates_queued_job_with_zero_progress() { let mut jm = JobManager::default(); diff --git a/crates/hooks/src/lib.rs b/crates/hooks/src/lib.rs index 60ca14f1..35d6b1a1 100644 --- a/crates/hooks/src/lib.rs +++ b/crates/hooks/src/lib.rs @@ -72,7 +72,7 @@ pub enum HookEvent { /// mapping it to a more specific variant. GenericEventFrame { /// The raw event frame to forward. - frame: EventFrame, + frame: Box, }, } @@ -333,6 +333,21 @@ mod tests { assert_eq!(encoded["payload"]["exit_code"], 0); } + #[test] + fn generic_event_frame_serialization_is_unchanged_by_boxing() { + let event = HookEvent::GenericEventFrame { + frame: Box::new(EventFrame::ResponseStart { + response_id: "resp-1".to_string(), + }), + }; + + let encoded = event.to_json(); + + assert_eq!(encoded["type"], "generic_event_frame"); + assert_eq!(encoded["frame"]["event"], "response_start"); + assert_eq!(encoded["frame"]["response_id"], "resp-1"); + } + #[tokio::test] async fn jsonl_sink_creates_parent_dir_and_appends_events() { let root = unique_temp_dir("jsonl_sink"); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 02f697fd..2e940e19 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -472,6 +472,9 @@ pub struct ExecApprovalRequestEvent { pub cwd: String, /// Human-readable reason why approval is needed. pub reason: String, + /// Policy rule that matched this approval request, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub matched_rule: Option>, /// Network context if the approval involves network access. #[serde(skip_serializing_if = "Option::is_none")] pub network_approval_context: Option, diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 454cb189..59c4d301 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -407,6 +407,10 @@ Common event names: `thread.started`, `thread.forked`, `turn.started`, `item.failed`, `item.interrupted`, `approval.required`, `approval.decided`, `approval.timeout`, `sandbox.denied`, `coherence.state`. +`approval.required` events may include a `matched_rule` string when an +execution-policy rule caused the prompt. This field is explanatory metadata for +clients and does not grant or persist permissions. + ## Security boundary - **Localhost by default**. The server binds to `127.0.0.1` by default. From eac4139e52633fd5ed0c58192221900ee2f212b0 Mon Sep 17 00:00:00 2001 From: wavezhang <832911+wavezhang@users.noreply.github.com> Date: Sat, 13 Jun 2026 01:53:42 +0800 Subject: [PATCH 17/25] feat: build static linux x64 binaries with musl (#2903) * feat: build static linux x64 binaries with musl Build codewhale-cli and codewhale-tui with x86_64-unknown-linux-musl target in the CNB tag_push workflow to produce fully static Linux x64 binaries. Install musl-tools instead of libdbus-1-dev; keep toolchain install and build in a single stage since CNB stages run in isolated containers without persistent state. Co-Authored-By: Claude Opus 4.7 * fix: gate keyring behind not(target_env = "musl") for static builds When targeting x86_64-unknown-linux-musl, the keyring crate's linux-native-sync-persistent feature pulls in libdbus-sys which cannot link against musl. Gate the OS keyring dependency behind not(target_env = "musl") so musl builds fall back to the file-backed secret store instead. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: wavezhang Co-authored-by: Claude Opus 4.7 --- .cnb.yml | 14 +++++++++----- crates/secrets/Cargo.toml | 2 +- crates/secrets/src/lib.rs | 18 +++++++++--------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.cnb.yml b/.cnb.yml index 1a9abfdf..af204c8a 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -113,20 +113,24 @@ $: - docker: image: rust:1.88-bookworm stages: - - name: build linux x64 release assets + - name: build linux x64 release assets (static) script: | set -eu apt-get update - apt-get install -y git libdbus-1-dev nodejs pkg-config + apt-get install -y git musl-tools nodejs pkg-config + rustup target add x86_64-unknown-linux-musl ./scripts/release/check-versions.sh ./scripts/release/check-ohos-deps.sh - cargo build --release --locked -p codewhale-cli -p codewhale-tui + cargo build --release --locked \ + --target x86_64-unknown-linux-musl \ + -p codewhale-cli -p codewhale-tui mkdir -p target/cnb-release - cp target/release/codewhale target/cnb-release/codewhale-linux-x64 - cp target/release/codewhale-tui target/cnb-release/codewhale-tui-linux-x64 + BIN_DIR="target/x86_64-unknown-linux-musl/release" + cp "$BIN_DIR/codewhale" target/cnb-release/codewhale-linux-x64 + cp "$BIN_DIR/codewhale-tui" target/cnb-release/codewhale-tui-linux-x64 strip \ target/cnb-release/codewhale-linux-x64 \ target/cnb-release/codewhale-tui-linux-x64 \ diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 7db781eb..58c804fb 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -19,7 +19,7 @@ keyring = { version = "3", features = ["apple-native"] } [target.'cfg(target_os = "windows")'.dependencies] keyring = { version = "3", features = ["windows-native"] } -[target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies] +[target.'cfg(all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")))'.dependencies] keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } [dev-dependencies] diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 53e4012a..81c7047e 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -127,7 +127,7 @@ impl DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) ))] { // `Entry::new` is enough to validate the native macOS/Windows @@ -156,7 +156,7 @@ impl DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) )))] { let _ = &self.service; @@ -170,7 +170,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) ))] { let entry = keyring::Entry::new(&self.service, key) @@ -184,7 +184,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) )))] { let _ = key; @@ -196,7 +196,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) ))] { let entry = keyring::Entry::new(&self.service, key) @@ -208,7 +208,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) )))] { let _ = (key, value); @@ -220,7 +220,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) ))] { let entry = keyring::Entry::new(&self.service, key) @@ -233,7 +233,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) )))] { let _ = key; @@ -249,7 +249,7 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos")) + all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) )))] fn unsupported_keyring_message() -> String { "system keyring backend is unsupported on this platform".to_string() From 19c73a7705030aeda319153a7bed4e19ef1636d9 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 12 Jun 2026 11:34:22 -0700 Subject: [PATCH 18/25] chore(fmt): format crates/secrets/src/lib.rs (#3173) Co-authored-by: CodeWhale Agent --- crates/secrets/src/lib.rs | 54 ++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 81c7047e..9e97286f 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -127,7 +127,11 @@ impl DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) ))] { // `Entry::new` is enough to validate the native macOS/Windows @@ -156,7 +160,11 @@ impl DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) )))] { let _ = &self.service; @@ -170,7 +178,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) ))] { let entry = keyring::Entry::new(&self.service, key) @@ -184,7 +196,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) )))] { let _ = key; @@ -196,7 +212,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) ))] { let entry = keyring::Entry::new(&self.service, key) @@ -208,7 +228,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) )))] { let _ = (key, value); @@ -220,7 +244,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) ))] { let entry = keyring::Entry::new(&self.service, key) @@ -233,7 +261,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) )))] { let _ = key; @@ -249,7 +281,11 @@ impl KeyringStore for DefaultKeyringStore { #[cfg(not(any( target_os = "macos", target_os = "windows", - all(target_os = "linux", not(target_env = "ohos"), not(target_env = "musl")) + all( + target_os = "linux", + not(target_env = "ohos"), + not(target_env = "musl") + ) )))] fn unsupported_keyring_message() -> String { "system keyring backend is unsupported on this platform".to_string() From 4d0c04ea7ca09935cff6e9a99c2a35c0db786758 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 12 Jun 2026 11:39:14 -0700 Subject: [PATCH 19/25] chore(fmt): format commands/mod.rs and commands/plugins.rs (#3174) Co-authored-by: CodeWhale Agent --- crates/tui/src/commands/mod.rs | 2 +- crates/tui/src/commands/plugins.rs | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index dfb93d32..93854ae7 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -19,8 +19,8 @@ mod jobs; mod mcp; mod memory; mod network; -mod plugins; mod note; +mod plugins; mod provider; mod queue; mod rename; diff --git a/crates/tui/src/commands/plugins.rs b/crates/tui/src/commands/plugins.rs index 6a371381..0e230b7a 100644 --- a/crates/tui/src/commands/plugins.rs +++ b/crates/tui/src/commands/plugins.rs @@ -5,8 +5,8 @@ use std::path::PathBuf; use crate::commands::CommandResult; use crate::config::Config; use crate::localization::{MessageId, tr}; -use crate::tui::app::App; use crate::tools::plugin::scan_plugin_dir; +use crate::tui::app::App; /// List discovered plugins, or show details for a named plugin. pub fn plugins(app: &mut App, arg: Option<&str>) -> CommandResult { @@ -92,8 +92,7 @@ fn show_plugin_detail( )); out.push_str(&format!( "{}\n", - tr(app.ui_locale, MessageId::CmdPluginDetailApproval) - .replace("{approval}", approval) + tr(app.ui_locale, MessageId::CmdPluginDetailApproval).replace("{approval}", approval) )); out.push_str(&format!( "{}\n", @@ -115,8 +114,9 @@ fn approval_label(approval: crate::tools::spec::ApprovalRequirement) -> &'static /// Resolve the configured plugin directory, defaulting to `~/.codewhale/tools`. fn plugin_dir_for(app: &App) -> Option { let config = match &app.config_path { - Some(path) => Config::load(Some(path.clone()), app.config_profile.as_deref()) - .unwrap_or_default(), + Some(path) => { + Config::load(Some(path.clone()), app.config_profile.as_deref()).unwrap_or_default() + } None => Config::default(), }; @@ -142,7 +142,9 @@ mod tests { fn create_test_app_with_plugin_dir(plugin_dir: &std::path::Path) -> (App, TempDir) { let tmp = TempDir::new().expect("tempdir"); let config_path = tmp.path().join("config.toml"); - let tools_dir = plugin_dir.canonicalize().unwrap_or_else(|_| plugin_dir.to_path_buf()); + let tools_dir = plugin_dir + .canonicalize() + .unwrap_or_else(|_| plugin_dir.to_path_buf()); std::fs::write( &config_path, format!( From bcb8af98d09c8983dc9ad9d3b4cd6804f4a2b595 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Fri, 12 Jun 2026 11:44:45 -0700 Subject: [PATCH 20/25] fix(cli): add missing verbosity field to ResolvedRuntimeOptions tests (#3175) Co-authored-by: CodeWhale Agent --- crates/cli/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 146e25a6..19cfbc73 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -3865,6 +3865,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; let err = build_tui_command(&cli, &options, vec![]).unwrap_err(); @@ -3900,6 +3901,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; let err = build_tui_command(&cli, &options, vec![]).unwrap_err(); @@ -3938,6 +3940,7 @@ mod tests { approval_policy: None, sandbox_mode: None, yolo: None, + verbosity: None, http_headers: std::collections::BTreeMap::new(), }; let cmd = build_tui_command(&cli, &options, vec![]).expect("should graceful fallback"); From ec5b0791be0423864ee06e3519a47a8b49fb0c79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:43:59 -0700 Subject: [PATCH 21/25] chore(deps): bump objc2 from 0.6.3 to 0.6.4 (#3186) Bumps [objc2](https://github.com/madsmtm/objc2) from 0.6.3 to 0.6.4. - [Commits](https://github.com/madsmtm/objc2/compare/objc2-0.6.3...objc2-0.6.4) --- updated-dependencies: - dependency-name: objc2 dependency-version: 0.6.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2afbfd8..5b4c56b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3356,9 +3356,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] From 9373c5d6b20b1ca1cfb869389a48d58a92869581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:44:06 -0700 Subject: [PATCH 22/25] chore(deps): bump flate2 from 1.1.5 to 1.1.9 (#3185) Bumps [flate2](https://github.com/rust-lang/flate2-rs) from 1.1.5 to 1.1.9. - [Release notes](https://github.com/rust-lang/flate2-rs/releases) - [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.5...1.1.9) --- updated-dependencies: - dependency-name: flate2 dependency-version: 1.1.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b4c56b1..97f4cd19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1939,9 +1939,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", From 45f334828f5b73d506e0e05daaccdd6339f11d6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:44:13 -0700 Subject: [PATCH 23/25] chore(deps): bump pdf-extract from 0.7.12 to 0.10.0 (#3184) Bumps [pdf-extract](https://github.com/jrmuizel/pdf-extract) from 0.7.12 to 0.10.0. - [Commits](https://github.com/jrmuizel/pdf-extract/compare/v0.7.12...v0.10.0) --- updated-dependencies: - dependency-name: pdf-extract dependency-version: 0.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 121 +++++++++++++++++++++++++++++++++--------- crates/tui/Cargo.toml | 2 +- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97f4cd19..c8578018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -656,6 +656,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cff-parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f5b6e9141c036f3ff4ce7b2f7e432b0f00dee416ddcd4f17741d189ddc2e9d" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1676,6 +1682,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -2982,19 +2997,29 @@ dependencies = [ [[package]] name = "lopdf" -version = "0.34.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +checksum = "c7184fdea2bc3cd272a1acec4030c321a8f9875e877b3f92a53f2f6033fdc289" dependencies = [ + "aes", + "bitflags 2.12.1", + "cbc", + "ecb", "encoding_rs", "flate2", + "getrandom 0.3.4", "indexmap", "itoa", "log", "md-5", - "nom 7.1.3", + "nom 8.0.0", + "nom_locate", + "rand 0.9.4", "rangemap", - "time", + "sha2 0.10.9", + "stringprep", + "thiserror 2.0.18", + "ttf-parser", "weezl", ] @@ -3513,13 +3538,15 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pdf-extract" -version = "0.7.12" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +checksum = "1e28ba1758a3d3f361459645780e09570b573fc3c82637449e9963174c813a98" dependencies = [ "adobe-cmap-parser", + "cff-parser", "encoding_rs", "euclid 0.20.14", + "log", "lopdf", "postscript", "type1-encoding-parser", @@ -3639,7 +3666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.6", ] [[package]] @@ -3890,8 +3917,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3901,7 +3938,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3913,6 +3960,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -4467,7 +4523,7 @@ dependencies = [ "hkdf", "num", "once_cell", - "rand", + "rand 0.8.6", "serde", "sha2 0.10.9", "zbus", @@ -4899,6 +4955,17 @@ dependencies = [ "precomputed-hash", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.10.0" @@ -5211,14 +5278,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -5227,16 +5292,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -5528,6 +5583,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "type1-encoding-parser" version = "0.1.0" @@ -5586,6 +5647,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -5613,6 +5680,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -6529,7 +6602,7 @@ dependencies = [ "hex", "nix 0.29.0", "ordered-stream", - "rand", + "rand 0.8.6", "serde", "serde_repr", "sha1", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 57cd0c94..7737b7ed 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -68,7 +68,7 @@ ignore = "0.4" image = { version = "0.25", default-features = false, features = ["png"] } lru = "0.16" parking_lot = "0.12" -pdf-extract = "0.7" +pdf-extract = "0.10" tar = "0.4" flate2 = "1.1" sha2 = "0.10" From d818ac4e2a7004ba2d1061a9cfabcb8d1f191b08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:44:19 -0700 Subject: [PATCH 24/25] build(deps): bump image from 0.25.9 to 0.25.10 (#3000) Bumps [image](https://github.com/image-rs/image) from 0.25.9 to 0.25.10. - [Changelog](https://github.com/image-rs/image/blob/v0.25.10/CHANGES.md) - [Commits](https://github.com/image-rs/image/compare/v0.25.9...v0.25.10) --- updated-dependencies: - dependency-name: image dependency-version: 0.25.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8578018..42cc4742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2578,9 +2578,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -3168,9 +3168,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -5259,9 +5259,9 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", @@ -6742,15 +6742,15 @@ checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" [[package]] name = "zune-core" -version = "0.4.12" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] From 7856d26d64773a3d75d5451d747f58ce463b5370 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:44:26 -0700 Subject: [PATCH 25/25] build(deps): bump tempfile from 3.24.0 to 3.27.0 (#2997) Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.24.0 to 3.27.0. - [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md) - [Commits](https://github.com/Stebalien/tempfile/compare/v3.24.0...v3.27.0) --- updated-dependencies: - dependency-name: tempfile dependency-version: 3.27.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 36 ++++++++++++++++++------------------ crates/app-server/Cargo.toml | 2 +- crates/cli/Cargo.toml | 2 +- crates/secrets/Cargo.toml | 2 +- crates/tui/Cargo.toml | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42cc4742..1713cc75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.3", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -311,7 +311,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] @@ -337,7 +337,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -1249,7 +1249,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.1.3", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1899,7 +1899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.59.0", ] @@ -2130,7 +2130,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2941,9 +2941,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3763,7 +3763,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -4267,14 +4267,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.12.1", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5093,14 +5093,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5121,7 +5121,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6525,7 +6525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -6542,7 +6542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.3", + "rustix 1.1.4", ] [[package]] diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index fec80642..880d75c6 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -28,5 +28,5 @@ tracing.workspace = true uuid.workspace = true [dev-dependencies] -tempfile = "3.16" +tempfile = "3.27" tower = "0.5" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index af734f44..3abeadf3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -36,7 +36,7 @@ rustls.workspace = true semver.workspace = true tokio.workspace = true sha2.workspace = true -tempfile = "3.16" +tempfile = "3.27" tracing.workspace = true [dev-dependencies] diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 58c804fb..1bc9ef60 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -23,4 +23,4 @@ keyring = { version = "3", features = ["windows-native"] } keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] } [dev-dependencies] -tempfile = "3.16" +tempfile = "3.27" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 7737b7ed..09347ec3 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -55,7 +55,7 @@ unicode-width = "0.2" unicode-segmentation = "1.12" uuid = { version = "1.11", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } -tempfile = "3.16" +tempfile = "3.27" thiserror = "2.0" tracing = "0.1" tracing-subscriber = { workspace = true }