use super::schema::{Category, EditorKind, SettingDef};
use agent_engine::session::SessionSetting;
pub(crate) enum SettingApply {
Session(SessionSetting),
Local(Result<(), String>),
}
macro_rules! define_settings {
($(
$key:literal, $label:expr, $category:ident, $editor:expr, $help:expr,
$apply:expr;
)*) => {
pub(crate) const ALL_SETTINGS: &[SettingDef] = &[
$(
SettingDef {
key: $key,
label: $label,
category: Category::$category,
editor: $editor,
help: $help,
},
)*
];
pub(crate) fn apply_setting_dispatch(
key: &str,
value: &str,
app: &mut crate::tui::app::App,
) -> SettingApply {
match key {
$(
$key => {
let handler: fn(&mut crate::tui::app::App, &str) -> SettingApply = $apply;
handler(app, value)
}
)*
_ => SettingApply::Local(Ok(()))
}
}
};
}
define_settings! {
"model", "Model", Model, EditorKind::ModelPicker,
"Which Claude model to use.",
|_app, value| SettingApply::Session(SessionSetting::Model { model: value.to_string() });
"thinking", "Thinking", Model,
EditorKind::DynamicCycler,
"Thinking depth — controls effort on adaptive models, budget on legacy.",
|app, value| {
use agent_core::reasoning::ReasoningLevel;
if let Some(level) = ReasoningLevel::parse(value) {
if let Err(e) = synaps_cli::runtime::openai::catalog::validation::validate_reasoning_mutation(
&app.session.model, level,
) {
return SettingApply::Local(Err(e));
}
SettingApply::Session(SessionSetting::ReasoningLevel { level })
} else {
SettingApply::Local(Ok(()))
}
};
"reasoning_type", "Reasoning", Model,
EditorKind::Display,
"How the active model expresses reasoning depth (derived from exact model capabilities; read-only).",
|_app, _value| { SettingApply::Local(Ok(())) };
"context_window", "Context window", Model,
EditorKind::Cycler(&["200k", "1m", "auto"]),
"Override context window limit (auto = model default).",
|_app, value| {
let window = match value {
"200k" | "200K" => Some(200_000u64),
"1m" | "1M" => Some(1_000_000u64),
"auto" => None,
_ => return SettingApply::Local(Ok(())),
};
SettingApply::Session(SessionSetting::ContextWindow { tokens: window })
};
"compaction_model", "Compaction model", Model,
EditorKind::ModelPicker,
"Model used for /compact (default: claude-sonnet-4-6).",
|_app, value| {
let model = if value.is_empty() || value == "auto" || value == "default" {
None
} else {
Some(value.to_string())
};
SettingApply::Session(SessionSetting::CompactionModel { model })
};
"api_retries", "API retries", Agent, EditorKind::Text { numeric: true },
"Retries on transient API errors.",
|_app, value| match value.parse::<u32>() {
Ok(n) => SettingApply::Session(SessionSetting::ApiRetries { n }),
Err(_) => SettingApply::Local(Ok(())),
};
"subagent_timeout", "Subagent timeout", Agent, EditorKind::Text { numeric: true },
"Seconds before a dispatched subagent is canceled.",
|_app, value| match value.parse::<u64>() {
Ok(secs) => SettingApply::Session(SessionSetting::SubagentTimeout { secs }),
Err(_) => SettingApply::Local(Ok(())),
};
"max_tool_output", "Max tool output", ToolLimits, EditorKind::Text { numeric: true },
"Bytes to capture from a tool before truncating.",
|_app, value| match value.parse::<usize>() {
Ok(bytes) => SettingApply::Session(SessionSetting::MaxToolOutput { bytes }),
Err(_) => SettingApply::Local(Ok(())),
};
"bash_timeout", "Bash timeout", ToolLimits, EditorKind::Text { numeric: true },
"Default seconds allowed for a bash command.",
|_app, value| match value.parse::<u64>() {
Ok(secs) => SettingApply::Session(SessionSetting::BashTimeout { secs }),
Err(_) => SettingApply::Local(Ok(())),
};
"bash_max_timeout", "Bash max timeout", ToolLimits, EditorKind::Text { numeric: true },
"Legacy setting retained for config compatibility; requested bash timeouts are no longer clamped.",
|_app, value| match value.parse::<u64>() {
Ok(secs) => SettingApply::Session(SessionSetting::BashMaxTimeout { secs }),
Err(_) => SettingApply::Local(Ok(())),
};
"theme", "Theme", Appearance, EditorKind::ThemePicker,
"Color theme (restart required).",
|_app, _value| { SettingApply::Local(Ok(())) };
"tui_background_opaque", "Background", Appearance, EditorKind::Cycler(&["opaque", "invisible"]),
"Opaque paints Synaps' theme background; invisible uses your terminal background.",
|_app, value| {
match value {
"opaque" => super::super::theme::set_background_opaque(true),
"invisible" => super::super::theme::set_background_opaque(false),
_ => return SettingApply::Local(Err("expected opaque or invisible".to_string())),
}
SettingApply::Local(Ok(()))
};
"theme_transition", "Theme transition", Appearance, EditorKind::Cycler(&["on", "off"]),
"Animated cross-fade on theme changes (on = 350ms). Off = instant snap. Integer ms (0-2000) accepted in the config file.",
|_app, value| {
match synaps_cli::config::ThemeTransitionMode::parse(value) {
Some(mode) => {
super::super::theme::transition::set_transition_mode(mode);
SettingApply::Local(Ok(()))
}
None => SettingApply::Local(Err("expected on, off, or milliseconds (0-2000)".to_string())),
}
};
"sidecar_toggle_key", "Sidecar toggle key", Sidecar,
EditorKind::Cycler(&["F8", "F2", "F12", "C-V", "C-G"]),
"Keybind that toggles the active sidecar plugin. Takes effect immediately.",
|app, value| {
if let Some(kb) = app.keybinds.as_ref() {
match kb.write() {
Ok(mut g) => {
if let Err(e) = g.set_slash_command_key("sidecar toggle", value) {
tracing::warn!("sidecar_toggle_key apply failed: {}", e);
}
}
Err(_) => tracing::warn!("sidecar_toggle_key apply: registry poisoned"),
}
}
SettingApply::Local(Ok(()))
};
"startup.quick_start", "Quick start", Startup, EditorKind::Cycler(&["on", "off"]),
"Skip waiting for extensions to finish loading before the first turn (faster startup; a tool from a slow extension may be unavailable on turn 1). Takes effect next launch.",
|_app, value| match value {
"on" | "off" => SettingApply::Local(Ok(())),
_ => SettingApply::Local(Err("expected on or off".to_string())),
};
"startup.extensions_ready_timeout_secs", "Extensions-ready wait", Startup,
EditorKind::Text { numeric: true },
"Max seconds to wait for extensions when Quick Start is off. Takes effect next launch.",
|_app, value| match value.parse::<u64>() {
Ok(_) => SettingApply::Local(Ok(())),
Err(_) => SettingApply::Local(Err("expected a number of seconds".to_string())),
};
"daemon.idle_exit_secs", "Daemon idle timeout", Daemon,
EditorKind::Text { numeric: true },
"Seconds the daemon stays alive after the last client disconnects (higher = stays warm, fewer cold starts). 0 = never idle-exit. Env SYNAPS_DAEMON_IDLE_EXIT_SECS overrides.",
|_app, value| match value.parse::<u64>() {
Ok(_) => SettingApply::Local(Ok(())),
Err(_) => SettingApply::Local(Err("expected a number of seconds".to_string())),
};
"daemon.prompt_abandon_secs", "Prompt-abandon timeout", Daemon,
EditorKind::Text { numeric: true },
"Seconds a pending confirmation on a detached session survives before it is auto-denied. 0 = disabled (survives forever). Env SYNAPS_DAEMON_PROMPT_ABANDON_SECS overrides.",
|_app, value| match value.parse::<u64>() {
Ok(_) => SettingApply::Local(Ok(())),
Err(_) => SettingApply::Local(Err("expected a number of seconds".to_string())),
};
"daemon.parked_evict_secs", "Parked eviction", Daemon,
EditorKind::Text { numeric: true },
"Seconds a parked session row lingers in the daemon before eviction (its state stays on disk; --continue rebuilds it). 0 = keep forever. Env SYNAPS_DAEMON_PARKED_EVICT_SECS overrides.",
|_app, value| match value.parse::<u64>() {
Ok(_) => SettingApply::Local(Ok(())),
Err(_) => SettingApply::Local(Err("expected a number of seconds".to_string())),
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn theme_transition_setting_is_appearance_cycler() {
let def = ALL_SETTINGS
.iter()
.find(|d| d.key == "theme_transition")
.expect("theme_transition setting should be defined");
assert_eq!(def.category, Category::Appearance);
match def.editor {
EditorKind::Cycler(opts) => assert_eq!(opts, &["on", "off"]),
_ => panic!("theme_transition editor should be a Cycler"),
}
}
#[test]
fn sidecar_toggle_key_setting_is_in_sidecar_category() {
let def = ALL_SETTINGS
.iter()
.find(|d| d.key == "sidecar_toggle_key")
.expect("sidecar_toggle_key setting should be defined");
assert_eq!(def.category, Category::Sidecar);
}
#[test]
fn quick_start_setting_is_in_startup_category() {
let def = ALL_SETTINGS
.iter()
.find(|d| d.key == "startup.quick_start")
.expect("startup.quick_start setting should be defined");
assert_eq!(def.category, Category::Startup);
match def.editor {
EditorKind::Cycler(opts) => assert_eq!(opts, &["on", "off"]),
_ => panic!("startup.quick_start editor should be a Cycler"),
}
}
#[test]
fn daemon_settings_are_in_daemon_category() {
for key in [
"daemon.idle_exit_secs",
"daemon.prompt_abandon_secs",
"daemon.parked_evict_secs",
] {
let def = ALL_SETTINGS
.iter()
.find(|d| d.key == key)
.unwrap_or_else(|| panic!("{key} setting should be defined"));
assert_eq!(def.category, Category::Daemon, "{key} should be in Daemon");
assert!(
matches!(def.editor, EditorKind::Text { numeric: true }),
"{key} should be a numeric Text editor"
);
}
}
#[test]
fn extensions_ready_timeout_is_in_startup_category() {
let def = ALL_SETTINGS
.iter()
.find(|d| d.key == "startup.extensions_ready_timeout_secs")
.expect("startup.extensions_ready_timeout_secs should be defined");
assert_eq!(def.category, Category::Startup);
assert!(matches!(def.editor, EditorKind::Text { numeric: true }));
}
#[test]
fn sidecar_toggle_key_static_setting_still_defined_for_backward_compat() {
let def = ALL_SETTINGS
.iter()
.find(|d| d.key == "sidecar_toggle_key")
.expect("sidecar_toggle_key setting must remain in ALL_SETTINGS for back-compat");
assert_eq!(def.category, Category::Sidecar);
match def.editor {
EditorKind::Cycler(opts) => {
assert!(opts.contains(&"F8"));
assert!(opts.contains(&"C-V"));
}
_ => panic!("expected Cycler editor for sidecar_toggle_key"),
}
}
fn app_with_model(model: &str) -> crate::tui::app::App {
crate::tui::app::App::new(synaps_cli::Session::new(model, "medium", None))
}
#[test]
fn thinking_dispatch_rejects_unsupported_level_returns_err_no_set() {
let mut app = app_with_model("openai-codex/gpt-5.6-luna"); match apply_setting_dispatch("thinking", "ultra", &mut app) {
SettingApply::Local(Err(_)) => {}
_ => panic!("ultra must be rejected for luna before any Set"),
}
}
#[test]
fn thinking_dispatch_accepts_valid_level_yields_set() {
let mut app = app_with_model("openai-codex/gpt-5.6-sol"); match apply_setting_dispatch("thinking", "ultra", &mut app) {
SettingApply::Session(SessionSetting::ReasoningLevel { level }) => {
assert_eq!(level, agent_core::reasoning::ReasoningLevel::Ultra)
}
_ => panic!("ultra must be accepted for sol"),
}
}
#[test]
fn thinking_dispatch_rejects_off_on_xai_45_no_set() {
let mut app = app_with_model("xai-auth/grok-4.5");
assert!(matches!(
apply_setting_dispatch("thinking", "off", &mut app),
SettingApply::Local(Err(_))
));
assert!(matches!(
apply_setting_dispatch("thinking", "xhigh", &mut app),
SettingApply::Local(Err(_))
));
assert!(matches!(
apply_setting_dispatch("thinking", "low", &mut app),
SettingApply::Session(SessionSetting::ReasoningLevel {
level: agent_core::reasoning::ReasoningLevel::Low
})
));
}
#[test]
fn runtime_keys_yield_the_expected_session_setting() {
let mut app = app_with_model("m");
assert!(matches!(
apply_setting_dispatch("model", "claude-opus-4-7", &mut app),
SettingApply::Session(SessionSetting::Model { model }) if model == "claude-opus-4-7"
));
assert!(matches!(
apply_setting_dispatch("api_retries", "5", &mut app),
SettingApply::Session(SessionSetting::ApiRetries { n: 5 })
));
assert!(matches!(
apply_setting_dispatch("bash_timeout", "30", &mut app),
SettingApply::Session(SessionSetting::BashTimeout { secs: 30 })
));
assert!(matches!(
apply_setting_dispatch("context_window", "1m", &mut app),
SettingApply::Session(SessionSetting::ContextWindow { tokens: Some(1_000_000) })
));
assert!(matches!(
apply_setting_dispatch("compaction_model", "auto", &mut app),
SettingApply::Session(SessionSetting::CompactionModel { model: None })
));
assert!(matches!(
apply_setting_dispatch("api_retries", "x", &mut app),
SettingApply::Local(Ok(()))
));
assert!(matches!(
apply_setting_dispatch("unknown_key_xyz", "val", &mut app),
SettingApply::Local(Ok(()))
));
}
}