pub(crate) mod defs;
pub(crate) mod schema;
pub(crate) mod draw;
pub(crate) mod input;
pub(crate) mod plugin_editor;
pub(crate) use draw::render;
pub(crate) use input::{handle_event, InputOutcome};
const BUILTIN_THEMES: &[&str] = &[
"default", "neon-rain", "amber", "phosphor", "solarized-dark", "blood",
"ocean", "rose-pine", "nord", "dracula", "monokai",
"gruvbox", "catppuccin", "tokyo-night", "sunset", "ice",
"forest", "lavender",
];
pub(crate) fn theme_options() -> Vec<String> {
let mut opts: Vec<String> = BUILTIN_THEMES.iter().map(|s| s.to_string()).collect();
if let Some(home) = std::env::var_os("HOME") {
let themes_dir = std::path::PathBuf::from(home).join(".synaps-cli/themes");
if let Ok(entries) = std::fs::read_dir(&themes_dir) {
for e in entries.flatten() {
if let Some(name) = e.path().file_stem().and_then(|s| s.to_str()) {
let s = name.to_string();
if !opts.contains(&s) { opts.push(s); }
}
}
}
}
opts
}
use schema::SettingDef;
pub(crate) struct PluginRow {
pub name: String,
pub skill_count: usize,
}
pub(crate) struct RuntimeSnapshot {
pub model: String,
pub thinking: String,
pub context_window: String,
pub compaction_model: String,
pub max_tool_output: usize,
pub bash_timeout: u64,
pub bash_max_timeout: u64,
pub subagent_timeout: u64,
pub api_retries: u32,
pub theme_name: String,
pub plugins: Vec<PluginRow>,
pub disabled_plugins: Vec<String>,
pub provider_keys: std::collections::BTreeMap<String, String>,
pub model_health: std::collections::HashMap<String, (synaps_cli::runtime::openai::ping::PingStatus, u64)>,
pub plugin_categories: Vec<synaps_cli::skills::registry::PluginSettingsCategory>,
pub lifecycle_claims: Vec<synaps_cli::skills::registry::LifecycleClaim>,
}
impl RuntimeSnapshot {
#[allow(dead_code)]
pub fn from_runtime(
runtime: &synaps_cli::Runtime,
registry: &synaps_cli::skills::registry::CommandRegistry,
) -> Self {
Self::from_runtime_with_health(runtime, registry, Default::default())
}
pub fn from_runtime_with_health(
runtime: &synaps_cli::Runtime,
registry: &synaps_cli::skills::registry::CommandRegistry,
model_health: std::collections::HashMap<String, (synaps_cli::runtime::openai::ping::PingStatus, u64)>,
) -> Self {
let config = synaps_cli::config::load_config();
let registry_map: std::collections::HashMap<String, usize> = registry
.plugins()
.into_iter()
.map(|p| (p.name, p.skill_count))
.collect();
let (all_plugins, _all_skills) =
synaps_cli::skills::loader::load_all(&synaps_cli::skills::loader::default_roots());
let mut plugins: Vec<PluginRow> = all_plugins
.into_iter()
.map(|p| {
let skill_count = registry_map.get(&p.name).copied().unwrap_or(0);
PluginRow { name: p.name, skill_count }
})
.collect();
plugins.sort_by(|a, b| a.name.cmp(&b.name));
Self {
model: runtime.model().to_string(),
thinking: runtime.thinking_level().to_string(),
compaction_model: runtime.compaction_model().to_string(),
context_window: {
match config.context_window {
Some(200_000) => "200k".to_string(),
Some(1_000_000) => "1m".to_string(),
Some(v) => v.to_string(),
None => "auto".to_string(),
}
},
max_tool_output: runtime.max_tool_output(),
bash_timeout: runtime.bash_timeout(),
bash_max_timeout: runtime.bash_max_timeout(),
subagent_timeout: runtime.subagent_timeout(),
api_retries: runtime.api_retries(),
theme_name: config.theme.unwrap_or_else(|| "(default)".to_string()),
plugins,
disabled_plugins: config.disabled_plugins.clone(),
provider_keys: config.provider_keys.clone(),
model_health,
plugin_categories: registry.plugin_settings_categories(),
lifecycle_claims: registry.lifecycle_claims(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Focus {
Left,
Right,
}
pub(super) enum ActiveEditor {
Text { buffer: String, setting_key: &'static str, numeric: bool, error: Option<String> },
Picker { setting_key: &'static str, options: Vec<String>, cursor: usize },
CustomModel { buffer: String, setting_key: &'static str },
ApiKey { provider_id: String, buffer: String },
PluginText {
plugin_id: String,
key: String,
buffer: String,
numeric: bool,
error: Option<String>,
},
PluginCustom {
plugin_id: String,
category: String,
field: String,
render: crate::chatui::settings::plugin_editor::PluginEditorSession,
},
}
pub(super) struct SettingsState {
pub category_idx: usize,
pub setting_idx: usize,
pub focus: Focus,
pub edit_mode: Option<ActiveEditor>,
pub row_error: Option<(String, String)>,
pub original_theme_name: Option<String>,
}
impl SettingsState {
pub fn new() -> Self {
Self {
category_idx: 0,
setting_idx: 0,
focus: Focus::Left,
edit_mode: None,
row_error: None,
original_theme_name: None,
}
}
pub fn current_settings(&self) -> Vec<&'static SettingDef> {
let cat = schema::CATEGORIES[self.category_idx];
if cat == schema::Category::Plugins || cat == schema::Category::Providers {
return Vec::new();
}
schema::ALL_SETTINGS.iter().filter(|s| s.category == cat).collect()
}
pub fn current_setting(&self) -> Option<&'static SettingDef> {
self.current_settings().get(self.setting_idx).copied()
}
pub fn is_plugin_category(&self, snap: &RuntimeSnapshot) -> bool {
let n_builtin = schema::visible_categories(&snap.lifecycle_claims).len();
self.category_idx >= n_builtin
&& self.category_idx - n_builtin < snap.plugin_categories.len()
}
pub fn current_plugin_category<'a>(
&self,
snap: &'a RuntimeSnapshot,
) -> Option<&'a synaps_cli::skills::registry::PluginSettingsCategory> {
if !self.is_plugin_category(snap) {
return None;
}
snap.plugin_categories.get(self.category_idx - schema::visible_categories(&snap.lifecycle_claims).len())
}
pub fn current_plugin_field<'a>(
&self,
snap: &'a RuntimeSnapshot,
) -> Option<&'a synaps_cli::skills::registry::PluginSettingsField> {
self.current_plugin_category(snap)
.and_then(|c| c.fields.get(self.setting_idx))
}
}
#[cfg(test)]
mod wireup_tests {
use super::*;
use synaps_cli::skills::registry::LifecycleClaim;
fn snap_with_claims(claims: Vec<LifecycleClaim>) -> RuntimeSnapshot {
RuntimeSnapshot {
model: "m".into(),
thinking: "medium".into(),
context_window: "auto".into(),
compaction_model: "m".into(),
max_tool_output: 0,
bash_timeout: 0,
bash_max_timeout: 0,
subagent_timeout: 0,
api_retries: 0,
theme_name: "t".into(),
plugins: Vec::new(),
disabled_plugins: Vec::new(),
provider_keys: std::collections::BTreeMap::new(),
model_health: std::collections::HashMap::new(),
plugin_categories: Vec::new(),
lifecycle_claims: claims,
}
}
fn claim(settings_category: Option<&str>) -> LifecycleClaim {
LifecycleClaim {
plugin: "p".into(),
command: "capture".into(),
settings_category: settings_category.map(|s| s.into()),
display_name: "Sample".into(),
importance: 0,
}
}
#[test]
fn visible_categories_excludes_sidecar_when_claim_present() {
let snap = snap_with_claims(vec![claim(Some("capture"))]);
let v = schema::visible_categories(&snap.lifecycle_claims);
assert!(!v.contains(&schema::Category::Sidecar));
}
#[test]
fn visible_categories_includes_sidecar_when_no_claim_settings_category() {
let snap_empty = snap_with_claims(Vec::new());
assert!(schema::visible_categories(&snap_empty.lifecycle_claims)
.contains(&schema::Category::Sidecar));
let snap_no_cat = snap_with_claims(vec![claim(None)]);
assert!(schema::visible_categories(&snap_no_cat.lifecycle_claims)
.contains(&schema::Category::Sidecar));
}
}