Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions crates/tui/src/commands/groups/config/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ use crate::config::{
normalize_model_name_for_provider,
};
use crate::config_persistence::{
persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key,
persist_tui_integer_key,
persist_feature_bool_key, persist_provider_base_url_key, persist_root_bool_key,
persist_root_string_key, persist_tui_integer_key,
};
use crate::config_ui::{ConfigUiMode, parse_mode};
use crate::features::Feature;
use crate::localization::resolve_locale;
use crate::settings::Settings;
use crate::tui::app::{
Expand Down Expand Up @@ -126,6 +127,11 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
Some(app.model.clone())
}
}
key if is_subagents_config_key(key) => {
return CommandResult::message(subagents_status_message(
app.features.enabled(Feature::Subagents),
));
}
"provider" => Some(app.api_provider.as_str().to_string()),
"approval_mode" | "approval" => Some(app.approval_mode.label().to_string()),
"allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()),
Expand Down Expand Up @@ -427,11 +433,73 @@ fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
}
}

fn is_subagents_config_key(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"subagents"
| "sub-agents"
| "features.subagents"
| "features.sub-agents"
| "feature.subagents"
| "feature.sub-agents"
)
}
Comment on lines +436 to +446

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To make the key matching more robust and user-friendly, we should:

  1. Convert the input key to lowercase (using to_ascii_lowercase()) to ensure case-insensitive matching when queried via /config (since show_single_setting might not lowercase the key).
  2. Support hyphenated variants under the features. and feature. prefixes (e.g., features.sub-agents and feature.sub-agents) for completeness, since sub-agents is already supported as a standalone key.
Suggested change
fn is_subagents_config_key(key: &str) -> bool {
matches!(
key,
"subagents" | "sub-agents" | "features.subagents" | "feature.subagents"
)
}
fn is_subagents_config_key(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"subagents"
| "sub-agents"
| "features.subagents"
| "features.sub-agents"
| "feature.subagents"
| "feature.sub-agents"
)
}


fn subagents_status_message(enabled: bool) -> String {
let state = if enabled { "enabled" } else { "disabled" };
format!("subagents = {enabled} ({state}; controls the model-facing agent tool)")
}

fn set_subagents_feature(app: &mut App, enabled: bool, persist: bool) -> CommandResult {
let suffix = if persist {
match persist_feature_bool_key(app.config_path.as_deref(), "subagents", enabled) {
Ok(path) => format!(" (saved to {})", path.display()),
Err(err) => return CommandResult::error(format!("Failed to save: {err}")),
}
} else {
" (session only, add --save to persist)".to_string()
};

if enabled {
app.features.enable(Feature::Subagents);
} else {
app.features.disable(Feature::Subagents);
}

let tool_hint = if enabled {
" The agent tool will be available on the next turn."
} else {
" The agent tool will be hidden on the next turn."
};

CommandResult::with_message_and_action(
format!("subagents = {enabled}{suffix}.{tool_hint}"),
AppAction::UpdateFeatures(app.features.clone()),
)
}

/// Modify a setting at runtime
pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult {
let key = key.to_lowercase();

match key.as_str() {
key if is_subagents_config_key(key) => {
let value = value.trim();
if value.is_empty()
|| value.eq_ignore_ascii_case("status")
|| value.eq_ignore_ascii_case("show")
|| value.eq_ignore_ascii_case("get")
{
return CommandResult::message(subagents_status_message(
app.features.enabled(Feature::Subagents),
));
}
let enabled = match parse_config_bool(value) {
Ok(enabled) => enabled,
Err(err) => return CommandResult::error(err),
};
return set_subagents_feature(app, enabled, persist);
}
"model" => {
// Support "/model auto" — auto-select model based on request complexity
if value.trim().eq_ignore_ascii_case("auto") {
Expand Down Expand Up @@ -1173,6 +1241,7 @@ mod tests {
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::tempdir;

struct EnvGuard {
home: Option<OsString>,
Expand Down Expand Up @@ -1651,6 +1720,73 @@ mod tests {
assert!(msg.contains("Failed to parse boolean 'maybe'"));
}

#[test]
fn config_command_subagents_off_updates_session_features() {
let mut app = create_test_app();
assert!(app.features.enabled(Feature::Subagents));

let result = config_command(&mut app, Some("subagents off"));

assert!(!result.is_error);
assert!(!app.features.enabled(Feature::Subagents));
assert!(matches!(
result.action,
Some(AppAction::UpdateFeatures(ref features))
if !features.enabled(Feature::Subagents)
));
let msg = result.message.unwrap();
assert!(msg.contains("subagents = false"));
assert!(msg.contains("session only"));
assert!(msg.contains("agent tool"));
assert!(msg.contains("next turn"));
}

#[test]
fn config_command_subagents_status_reports_effective_state() {
let mut app = create_test_app();
app.features.disable(Feature::Subagents);

let result = config_command(&mut app, Some("subagents status"));

assert!(!result.is_error);
assert!(result.action.is_none());
let msg = result.message.unwrap();
assert!(msg.contains("subagents = false"));
assert!(msg.contains("disabled"));
}

#[test]
fn config_command_subagents_save_persists_features_table() {
let temp_root = tempdir().expect("tempdir");
let config_path = temp_root.path().join("custom-config.toml");

let mut app = create_test_app();
app.config_path = Some(config_path.clone());

let disabled = config_command(&mut app, Some("subagents off --save"));
assert!(!disabled.is_error);
assert!(!app.features.enabled(Feature::Subagents));
let saved = fs::read_to_string(&config_path).unwrap();
assert!(saved.contains("[features]"));
assert!(saved.contains("subagents = false"));
let reloaded = Config::load(Some(config_path.clone()), None).unwrap();
assert!(!reloaded.features().enabled(Feature::Subagents));

let enabled = config_command(&mut app, Some("subagents on --save"));
assert!(!enabled.is_error);
assert!(app.features.enabled(Feature::Subagents));
let saved = fs::read_to_string(&config_path).unwrap();
assert!(saved.contains("subagents = true"));
let reloaded = Config::load(Some(config_path.clone()), None).unwrap();
assert!(reloaded.features().enabled(Feature::Subagents));

let direct_key = config_command(&mut app, Some("features.subagents false --save"));
assert!(!direct_key.is_error);
assert!(!app.features.enabled(Feature::Subagents));
let reloaded = Config::load(Some(config_path), None).unwrap();
assert!(!reloaded.features().enabled(Feature::Subagents));
}

#[test]
fn config_command_base_url_without_save_requires_save() {
let _lock = lock_test_env();
Expand Down
24 changes: 15 additions & 9 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1780,12 +1780,12 @@ pub struct SubagentsConfig {
#[serde(default)]
pub max_concurrent: Option<usize>,
/// How many levels of nested sub-agents the interactive `agent` tool may
/// spawn. `0` disables sub-agents entirely — the `agent` tool refuses to
/// spawn, a full opt-out; `1` allows one level, `2` two, and so on. When
/// unset, defaults to [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; any value
/// is clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. Fleet
/// workers are governed separately by `[fleet.exec] max_spawn_depth`; both
/// share the same default and ceiling so the limit cannot drift.
/// spawn. `0` blocks spawning at the depth check while leaving the global
/// `features.subagents` flag untouched; `1` allows one level, `2` two, and
/// so on. When unset, defaults to [`codewhale_config::DEFAULT_SPAWN_DEPTH`];
/// any value is clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`].
/// Fleet workers are governed separately by `[fleet.exec] max_spawn_depth`;
/// both share the same default and ceiling so the limit cannot drift.
#[serde(default)]
pub max_depth: Option<u32>,
/// Number of direct (depth-1) sub-agents that may execute concurrently
Expand Down Expand Up @@ -3175,9 +3175,10 @@ impl Config {
/// How many levels of nested sub-agents the interactive `agent` tool may
/// spawn. Reads `[subagents] max_depth`; when unset it defaults to
/// [`codewhale_config::DEFAULT_SPAWN_DEPTH`]. `0` is a valid value that
/// disables sub-agent spawning entirely (full opt-out). Any value is
/// clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`] so the
/// operator's choice can never exceed the hard recursion ceiling.
/// blocks spawning at the depth check while leaving the global
/// `features.subagents` flag untouched. Any value is clamped to
/// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`] so the operator's choice
/// can never exceed the hard recursion ceiling.
#[must_use]
pub fn subagent_max_spawn_depth(&self) -> u32 {
self.subagents
Expand Down Expand Up @@ -7387,6 +7388,11 @@ action = "session.compact"
..Config::default()
};
assert_eq!(disabled.subagent_max_spawn_depth(), 0);
assert!(
disabled
.features()
.enabled(crate::features::Feature::Subagents)
);

let high = Config {
subagents: Some(SubagentsConfig {
Expand Down
75 changes: 75 additions & 0 deletions crates/tui/src/config_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,49 @@ pub(crate) fn persist_root_bool_key(
Ok(path)
}

pub(crate) fn persist_feature_bool_key(
config_path: Option<&Path>,
key: &str,
value: bool,
) -> anyhow::Result<PathBuf> {
use anyhow::Context;
use std::fs;

let path = config_toml_path(config_path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create config directory {}", parent.display()))?;
}

let (mut doc, original_raw) = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
let doc: toml::Value = toml::from_str(&raw)
.with_context(|| format!("failed to parse config at {}", path.display()))?;
(doc, Some(raw))
} else {
(toml::Value::Table(toml::value::Table::new()), None)
};
let table = doc
.as_table_mut()
.context("config.toml root must be a table")?;
let features_entry = table
.entry("features".to_string())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
let features = features_entry
.as_table_mut()
.context("`features` section in config.toml must be a table")?;
features.insert(key.to_string(), toml::Value::Boolean(value));
if let Some(raw) = original_raw {
save_toml_preserving_comments(&path, &doc, &raw)?;
} else {
let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?;
fs::write(&path, body)
.with_context(|| format!("failed to write config at {}", path.display()))?;
}
Ok(path)
}

pub(crate) fn persist_tui_integer_key(
config_path: Option<&Path>,
key: &str,
Expand Down Expand Up @@ -537,4 +580,36 @@ mod tests {
"new key not written: {body}"
);
}

#[test]
fn persist_feature_bool_key_writes_features_table_and_preserves_comments() {
let temp_root = temp_root("codewhale-persist-feature-comments");
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);

let path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
"# my note\nmodel = \"deepseek-v4-flash\"\n[features]\n# keep this nearby\nweb_search = true\n",
)
.unwrap();

let written = persist_feature_bool_key(Some(&path), "subagents", false)
.expect("persist should succeed");
let body = fs::read_to_string(&written).expect("written file should be readable");
assert!(body.contains("# my note"), "prefix comment lost: {body}");
assert!(
body.contains("# keep this nearby"),
"features comment lost: {body}"
);
assert!(
body.contains("web_search = true"),
"existing feature lost: {body}"
);
assert!(
body.contains("subagents = false"),
"subagent feature not written: {body}"
);
}
}
47 changes: 13 additions & 34 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,13 @@ impl Engine {
)))
.await;
}
Op::SetFeatures { features } => {
self.config.features = features;
let _ = self
.tx_event
.send(Event::status("Feature flags updated".to_string()))
.await;
}
Op::SetStreamChunkTimeout { timeout_secs } => {
self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs);
let _ = self
Expand Down Expand Up @@ -2106,40 +2113,12 @@ impl Engine {
let mut tool_registry = match mode {
AppMode::Agent | AppMode::Yolo => {
if self.config.features.enabled(Feature::Subagents) {
let runtime = if let Some(client) = self.deepseek_client.clone() {
let mut rt = SubAgentRuntime::new(
client,
self.session.model.clone(),
tool_context.clone(),
self.session.allow_shell,
Some(self.tx_event.clone()),
Arc::clone(&self.subagent_manager),
)
.with_role_models(self.config.subagent_model_overrides.clone())
.with_auto_model(self.session.auto_model)
.with_reasoning_effort(
self.session.reasoning_effort.clone(),
self.session.reasoning_effort_auto,
)
.with_max_spawn_depth(self.config.max_spawn_depth)
.with_step_api_timeout(self.config.subagent_api_timeout)
.with_speech_output_dir(self.config.speech_output_dir.clone())
.with_mcp_pool(mcp_pool.clone())
.with_todos(self.config.todos.clone())
.with_parent_completion_tx(self.tx_subagent_completion.clone());
if let Some(context) = fork_context_for_runtime.clone() {
rt = rt.with_fork_context(context);
}
if let Some((mailbox, cancel_token)) = mailbox_for_runtime.as_ref() {
rt = rt
.with_mailbox(mailbox.clone())
.with_cancel_token(cancel_token.clone());
}
Some(rt)
} else {
None
};
if let Some(subagent_runtime) = runtime {
if let Some(subagent_runtime) = self.build_parent_subagent_runtime(
tool_context.clone(),
mcp_pool.clone(),
fork_context_for_runtime.clone(),
mailbox_for_runtime.as_ref(),
) {
Some(
builder
.with_subagent_tools(
Expand Down
Loading