diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 319961130..837ce85c7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -27,5 +27,5 @@ {"_type":"issue","id":"nixmac-orl","title":"Migrate AppBehaviorPrefs (auto_summarize, dev_mode, etc.) to Configurable","description":"Replace auto_summarize_on_focus, scan_homebrew_on_startup, default_to_diff_tab, developer_mode, send_diagnostics in UiPrefs with an AppBehaviorPrefs Configurable struct. Five booleans currently get five hand-written branches in ui_set_prefs. Blocked by configurable PoC.","status":"open","priority":3,"issue_type":"task","owner":"me@cooperm.com","created_at":"2026-05-25T02:42:51Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T02:42:51Z","dependencies":[{"issue_id":"nixmac-orl","depends_on_id":"nixmac-8ka","type":"blocks","created_at":"2026-05-24T19:43:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"nixmac-5nc","title":"Migrate ConfirmationPrefs (confirm_build/clear/rollback) to Configurable","description":"Replace confirm_build, confirm_clear, confirm_rollback in UiPrefs with a ConfirmationPrefs Configurable struct. Three booleans currently each get their own store::set_bool_pref branch — replaces them all with one derive. Blocked by configurable PoC.","status":"open","priority":3,"issue_type":"task","owner":"me@cooperm.com","created_at":"2026-05-25T02:42:49Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T02:42:49Z","dependencies":[{"issue_id":"nixmac-5nc","depends_on_id":"nixmac-8ka","type":"blocks","created_at":"2026-05-24T19:43:10Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"nixmac-2rr","title":"Migrate ModelPrefs (provider+model selections) to Configurable","description":"Replace evolve_provider, evolve_model, summary_provider, summary_model in UiPrefs with a ModelPrefs Configurable struct. Lives near AI orchestration code. Blocked by configurable PoC.","status":"open","priority":3,"issue_type":"task","owner":"me@cooperm.com","created_at":"2026-05-25T02:42:45Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T02:42:45Z","dependencies":[{"issue_id":"nixmac-2rr","depends_on_id":"nixmac-8ka","type":"blocks","created_at":"2026-05-24T19:43:09Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"nixmac-e53","title":"Full UiPrefs → Configurable migration (umbrella)","description":"Once the PoC lands, dissolve the UiPrefs / UiPrefsUpdate god-blob in apps/native/src-tauri/src/shared_types/prefs.rs into ~6 domain-specific Configurable structs that live next to the code that uses them. Each sub-issue migrates one category and deletes the corresponding store::get_*/set_* helpers + ui_prefs.rs branches.\n\nEnd state: ui_prefs.rs commands shrink to ~10 lines (delegate to the registry). UiPrefs / UiPrefsUpdate structs are deleted. Adding a new knob = one struct field.","acceptance_criteria":"All sub-issues closed; UiPrefs/UiPrefsUpdate removed; ui_get_prefs/ui_set_prefs reduced to registry walkthrough; no per-field store::get_/set_ helpers remain except for genuinely non-trivial cases (e.g. keychain-backed api keys); apps/native/src/ipc/types.ts still resolves cleanly via specta","status":"open","priority":3,"issue_type":"feature","owner":"me@cooperm.com","created_at":"2026-05-25T02:42:33Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T02:42:33Z","dependencies":[{"issue_id":"nixmac-e53","depends_on_id":"nixmac-27g","type":"blocks","created_at":"2026-05-24T19:43:19Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-2rr","type":"blocks","created_at":"2026-05-24T19:43:14Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-2tt","type":"blocks","created_at":"2026-05-24T19:43:17Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-5nc","type":"blocks","created_at":"2026-05-24T19:43:15Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-elj","type":"blocks","created_at":"2026-05-24T19:43:18Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-orl","type":"blocks","created_at":"2026-05-24T19:43:16Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"nixmac-93p","title":"Auto-generated dev settings UI via Configurable registry","description":"Once all UiPrefs categories are migrated, add a Tauri command dev_configs_list() that walks the inventory registry and returns {struct_name, fields[{name, type, default, range, current_value}]} for every registered Configurable. Frontend renders a ConfigField\u003cT\u003e component per type (number/bool/string/enum). Adding a new struct = new section auto-appears. Future work after full migration.","status":"open","priority":4,"issue_type":"feature","owner":"me@cooperm.com","created_at":"2026-05-25T02:43:03Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T02:43:03Z","dependencies":[{"issue_id":"nixmac-93p","depends_on_id":"nixmac-e53","type":"blocks","created_at":"2026-05-24T19:43:20Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"nixmac-e53","title":"Full UiPrefs → Configurable migration (umbrella)","description":"Once the PoC lands, dissolve the UiPrefs / UiPrefsUpdate god-blob in apps/native/src-tauri/src/shared_types/prefs.rs into ~6 domain-specific Configurable structs that live next to the code that uses them. Each sub-issue migrates one category and deletes the corresponding store::get_*/set_* helpers + ui_prefs.rs branches.\n\nEnd state: ui_prefs.rs commands shrink to ~10 lines (delegate to the registry). UiPrefs / UiPrefsUpdate structs are deleted. Adding a new knob = one struct field.","acceptance_criteria":"All sub-issues closed; UiPrefs/UiPrefsUpdate removed; ui_get_prefs/ui_set_prefs reduced to registry walkthrough; no per-field store::get_/set_ helpers remain except for genuinely non-trivial cases (e.g. keychain-backed api keys); apps/native/src/ipc/types.ts still resolves cleanly via specta","status":"open","priority":3,"issue_type":"feature","owner":"me@cooperm.com","created_at":"2026-05-25T02:42:33Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T02:42:33Z","dependencies":[{"issue_id":"nixmac-e53","depends_on_id":"nixmac-27g","type":"blocks","created_at":"2026-05-24T19:43:19Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-2rr","type":"blocks","created_at":"2026-05-24T19:43:14Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-2tt","type":"blocks","created_at":"2026-05-24T19:43:17Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-5nc","type":"blocks","created_at":"2026-05-24T19:43:15Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-elj","type":"blocks","created_at":"2026-05-24T19:43:18Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"nixmac-e53","depends_on_id":"nixmac-orl","type":"blocks","created_at":"2026-05-24T19:43:16Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"nixmac-93p","title":"Auto-generated dev settings UI via Configurable registry","description":"Once all UiPrefs categories are migrated, add a Tauri command dev_configs_list() that walks the inventory registry and returns {struct_name, fields[{name, type, default, range, current_value}]} for every registered Configurable. Frontend renders a ConfigField\u003cT\u003e component per type (number/bool/string/enum). Adding a new struct = new section auto-appears. Future work after full migration.","status":"closed","priority":4,"issue_type":"feature","owner":"me@cooperm.com","created_at":"2026-05-25T02:43:03Z","created_by":"Cooper Maruyama","updated_at":"2026-05-25T05:12:22Z","closed_at":"2026-05-25T05:12:22Z","close_reason":"Shipped in f557789d. Inventory registry + rich schema + AutoConfigField. Adding a Configurable field auto-appears in Developer \u003e Tuning.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Cargo.lock b/Cargo.lock index e7def73f5..274a0a200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1012,8 +1012,10 @@ version = "0.1.0" dependencies = [ "anyhow", "configurable-derive", + "inventory", "serde", "serde_json", + "specta", "tauri", "tauri-plugin-store", ] @@ -3007,6 +3009,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.12.0" diff --git a/apps/native/src-tauri/configurable-derive/src/lib.rs b/apps/native/src-tauri/configurable-derive/src/lib.rs index 9f1636d38..4ff9637bc 100644 --- a/apps/native/src-tauri/configurable-derive/src/lib.rs +++ b/apps/native/src-tauri/configurable-derive/src/lib.rs @@ -1,14 +1,24 @@ -//! `#[derive(Configurable)]` generates an inherent `Foo::load(app)` method that -//! reads each field from `tauri-plugin-store`, falling back to the per-field -//! default when no stored value is present. +//! `#[derive(Configurable)]` — generates everything the runtime registry +//! needs to read, write, and render a settings struct without per-field +//! frontend code. //! -//! The companion `configurable` crate provides the runtime helper(s) used by the -//! generated code and re-exports this derive for end users. +//! Per struct, the derive generates: +//! - `load(app)` — read all fields from the store with defaults +//! - `schema(app)` — full UI schema with current values populated +//! - `set_field(app, key, value)` — write one field, type-checked +//! - Wry-specialized shims (`*_wry`) for the type-erased registry +//! - `inventory::submit!` — auto-registers the struct at startup +//! +//! The companion `configurable` crate provides the runtime helpers and +//! re-exports this derive for end users. use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::quote; -use syn::{parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprPath, Fields, LitStr}; +use syn::{ + parse_macro_input, Attribute, Data, DeriveInput, Expr, ExprArray, ExprLit, ExprPath, + ExprRange, Fields, Lit, LitStr, RangeLimits, Type, +}; /// How the generated `load()` method resolves the store file path: /// - `Const(s)` — fixed string, baked in at compile time @@ -18,6 +28,22 @@ enum StorePath { Fn(ExprPath), } +struct StructConfig { + store_path: StorePath, + display_name: Option, + description: Option, +} + +struct FieldConfig { + default: Option, + key: Option, + label: Option, + help: Option, + range: Option, + options: Option, + multiline: bool, +} + #[proc_macro_derive(Configurable, attributes(config))] pub fn derive_configurable(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); @@ -28,11 +54,20 @@ pub fn derive_configurable(input: TokenStream) -> TokenStream { fn expand(input: DeriveInput) -> syn::Result { let name = &input.ident; - let store_path = parse_struct_config(&input.attrs, name)?; + let name_str = name.to_string(); + let struct_config = parse_struct_config(&input.attrs, name)?; + let display_name = struct_config + .display_name + .clone() + .unwrap_or_else(|| name_str.clone()); + let description_expr = match &struct_config.description { + Some(s) => quote! { ::std::option::Option::Some(#s.to_string()) }, + None => quote! { ::std::option::Option::None }, + }; - // Resolve the store path inside the body of `load(app)` so that - // `store_path_fn` can use `app` and return a per-call value. - let store_path_binding: TokenStream2 = match &store_path { + // The store path is resolved inside each generated method's body so + // `store_path_fn` can use `app`. + let store_path_binding: TokenStream2 = match &struct_config.store_path { StorePath::Const(s) => quote! { let __store_path: &str = #s; }, StorePath::Fn(path) => quote! { let __store_path_string: ::std::string::String = #path(app)?; @@ -58,24 +93,66 @@ fn expand(input: DeriveInput) -> syn::Result { } }; - let field_loads: Vec = fields - .iter() - .map(|field| { - let ident = field.ident.as_ref().expect("named field has an ident"); - let ty = &field.ty; - let cfg = parse_field_config(&field.attrs, ident)?; - let key = cfg - .key - .unwrap_or_else(|| snake_to_camel(&ident.to_string())); - let default = cfg.default.ok_or_else(|| { - syn::Error::new_spanned(field, "field requires #[config(default = ...)]") - })?; - Ok(quote! { - #ident: ::configurable::read_field::(app, __store_path, #key)? - .unwrap_or_else(|| #default), - }) - }) - .collect::>>()?; + // Per-field codegen — collect into three buckets: + // load_inits : `field: read_or_default,` for the Self { ... } literal + // schema_fields : `ConfigField { ... },` per field + // set_field_arms : `"key" => { typecheck; write; },` for the match + let mut load_inits = Vec::new(); + let mut schema_fields = Vec::new(); + let mut set_field_arms = Vec::new(); + + for field in fields { + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "field must be named"))?; + let ty = &field.ty; + let cfg = parse_field_config(&field.attrs, ident)?; + + let key = cfg + .key + .clone() + .unwrap_or_else(|| snake_to_camel(&ident.to_string())); + let default = cfg.default.clone().ok_or_else(|| { + syn::Error::new_spanned(field, "field requires #[config(default = ...)]") + })?; + let label = cfg.label.clone().unwrap_or_else(|| humanize(&ident.to_string())); + let help_expr = match &cfg.help { + Some(h) => quote! { ::std::option::Option::Some(#h.to_string()) }, + None => quote! { ::std::option::Option::None }, + }; + let ty_expr = field_type_expr(ty, &cfg)?; + + load_inits.push(quote! { + #ident: ::configurable::read_field::(app, __store_path, #key)? + .unwrap_or_else(|| #default), + }); + + schema_fields.push(quote! { + ::configurable::ConfigField { + key: #key.to_string(), + label: #label.to_string(), + help: #help_expr, + ty: #ty_expr, + default: ::serde_json::json!(#default), + current: ::configurable::read_field::(app, __store_path, #key)? + .unwrap_or_else(|| ::serde_json::json!(#default)), + }, + }); + + set_field_arms.push(quote! { + #key => { + // Type-check: round-trip through the declared Rust type. + let _typed: #ty = ::serde_json::from_value(value.clone()) + .map_err(|e| ::anyhow::anyhow!( + "Configurable {}: invalid value for `{}`: {}", + #name_str, #key, e, + ))?; + ::configurable::write_field(app, __store_path, #key, value)?; + ::std::result::Result::Ok(()) + } + }); + } Ok(quote! { impl #name { @@ -84,25 +161,177 @@ fn expand(input: DeriveInput) -> syn::Result { ) -> ::std::result::Result { #store_path_binding ::std::result::Result::Ok(Self { - #(#field_loads)* + #(#load_inits)* + }) + } + + pub fn schema( + app: &::tauri::AppHandle, + ) -> ::std::result::Result<::configurable::ConfigurableSchema, ::anyhow::Error> { + #store_path_binding + ::std::result::Result::Ok(::configurable::ConfigurableSchema { + name: #name_str.to_string(), + display_name: #display_name.to_string(), + description: #description_expr, + fields: ::std::vec![ + #(#schema_fields)* + ], }) } + + pub fn set_field( + app: &::tauri::AppHandle, + key: &str, + value: ::serde_json::Value, + ) -> ::std::result::Result<(), ::anyhow::Error> { + #store_path_binding + match key { + #(#set_field_arms)* + other => ::std::result::Result::Err(::anyhow::anyhow!( + "Configurable {}: unknown field `{}`", + #name_str, other, + )), + } + } + + // Wry-specialized shims for the type-erased registry. Generic + // functions can't be cast to fn pointers; these monomorphic + // wrappers can. + #[doc(hidden)] + pub fn __configurable_schema_wry( + app: &::tauri::AppHandle<::tauri::Wry>, + ) -> ::std::result::Result<::configurable::ConfigurableSchema, ::anyhow::Error> { + Self::schema(app) + } + + #[doc(hidden)] + pub fn __configurable_set_field_wry( + app: &::tauri::AppHandle<::tauri::Wry>, + key: &str, + value: ::serde_json::Value, + ) -> ::std::result::Result<(), ::anyhow::Error> { + Self::set_field(app, key, value) + } + } + + ::configurable::inventory::submit! { + ::configurable::RegisteredConfig { + name: #name_str, + schema_fn: #name::__configurable_schema_wry, + set_field_fn: #name::__configurable_set_field_wry, + } } }) } -struct FieldConfig { - default: Option, - key: Option, +fn field_type_expr(ty: &Type, cfg: &FieldConfig) -> syn::Result { + // Explicit options attribute → Enum, regardless of Rust type. + if let Some(arr) = &cfg.options { + let variant_tokens: Vec = arr + .elems + .iter() + .map(|elem| { + let value_str = match elem { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => s.value(), + _ => { + return Err(syn::Error::new_spanned( + elem, + "#[config(options = [...])] entries must be string literals", + )) + } + }; + let label = humanize(&value_str); + Ok(quote! { + ::configurable::EnumVariant { + value: #value_str.to_string(), + label: #label.to_string(), + } + }) + }) + .collect::>()?; + return Ok(quote! { + ::configurable::FieldType::Enum { + variants: ::std::vec![ #(#variant_tokens),* ], + } + }); + } + + // Otherwise dispatch on the Rust type's last path segment. + let name = type_last_ident(ty).ok_or_else(|| { + syn::Error::new_spanned( + ty, + "Configurable: can't determine field type — add #[config(options = [...])] for enums", + ) + })?; + + match name.as_str() { + "u8" | "u16" | "u32" | "u64" | "usize" | "i8" | "i16" | "i32" | "i64" | "isize" + | "f32" | "f64" => { + let (min_expr, max_expr) = match &cfg.range { + Some(range) => range_bounds(range), + None => (quote! { ::std::option::Option::None }, quote! { ::std::option::Option::None }), + }; + Ok(quote! { + ::configurable::FieldType::Number { + min: #min_expr, + max: #max_expr, + step: ::std::option::Option::None, + } + }) + } + "bool" => Ok(quote! { ::configurable::FieldType::Boolean }), + "String" => { + let ml = cfg.multiline; + Ok(quote! { ::configurable::FieldType::String { multiline: #ml } }) + } + other => Err(syn::Error::new_spanned( + ty, + format!( + "Configurable: unsupported field type `{other}` — use a numeric primitive, bool, String, or supply #[config(options = [...])] for enums", + ), + )), + } +} + +fn range_bounds(range: &ExprRange) -> (TokenStream2, TokenStream2) { + let from = match &range.start { + Some(e) => quote! { ::std::option::Option::Some((#e) as f64) }, + None => quote! { ::std::option::Option::None }, + }; + let to = match &range.end { + Some(e) => { + let value = quote! { (#e) as f64 }; + match range.limits { + RangeLimits::Closed(_) => quote! { ::std::option::Option::Some(#value) }, + // For half-open ranges treat the bound as inclusive in the UI — + // HTML number inputs only understand inclusive max. + RangeLimits::HalfOpen(_) => quote! { ::std::option::Option::Some(#value) }, + } + } + None => quote! { ::std::option::Option::None }, + }; + (from, to) +} + +fn type_last_ident(ty: &Type) -> Option { + match ty { + Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()), + _ => None, + } } -fn parse_struct_config(attrs: &[Attribute], name: &syn::Ident) -> syn::Result { +fn parse_struct_config(attrs: &[Attribute], name: &syn::Ident) -> syn::Result { + let mut store_path: Option = None; + let mut store_path_fn: Option = None; + let mut display_name: Option = None; + let mut description: Option = None; + for attr in attrs { if !attr.path().is_ident("config") { continue; } - let mut store_path: Option = None; - let mut store_path_fn: Option = None; attr.parse_nested_meta(|meta| { if meta.path.is_ident("store_path") { let value = meta.value()?; @@ -113,31 +342,55 @@ fn parse_struct_config(attrs: &[Attribute], name: &syn::Ident) -> syn::Result()?); Ok(()) + } else if meta.path.is_ident("display_name") { + let value = meta.value()?; + let lit: LitStr = value.parse()?; + display_name = Some(lit.value()); + Ok(()) + } else if meta.path.is_ident("description") { + let value = meta.value()?; + let lit: LitStr = value.parse()?; + description = Some(lit.value()); + Ok(()) } else { Err(meta.error("unsupported #[config(...)] attribute on struct")) } })?; - match (store_path, store_path_fn) { - (Some(_), Some(_)) => { - return Err(syn::Error::new_spanned( - name, - "Configurable: pick either #[config(store_path = \"...\")] or #[config(store_path_fn = ...)], not both", - )); - } - (Some(s), None) => return Ok(StorePath::Const(s)), - (None, Some(p)) => return Ok(StorePath::Fn(p)), - (None, None) => continue, - } } - Err(syn::Error::new_spanned( - name, - "Configurable: missing #[config(store_path = \"...\")] or #[config(store_path_fn = path::to::resolver)] on struct", - )) + + let path = match (store_path, store_path_fn) { + (Some(_), Some(_)) => { + return Err(syn::Error::new_spanned( + name, + "Configurable: pick either #[config(store_path = \"...\")] or #[config(store_path_fn = ...)], not both", + )); + } + (Some(s), None) => StorePath::Const(s), + (None, Some(p)) => StorePath::Fn(p), + (None, None) => { + return Err(syn::Error::new_spanned( + name, + "Configurable: missing #[config(store_path = \"...\")] or #[config(store_path_fn = ...)] on struct", + )); + } + }; + + Ok(StructConfig { + store_path: path, + display_name, + description, + }) } fn parse_field_config(attrs: &[Attribute], ident: &syn::Ident) -> syn::Result { let mut default: Option = None; let mut key: Option = None; + let mut label: Option = None; + let mut help: Option = None; + let mut range: Option = None; + let mut options: Option = None; + let mut multiline = false; + for attr in attrs { if !attr.path().is_ident("config") { continue; @@ -152,6 +405,29 @@ fn parse_field_config(attrs: &[Attribute], ident: &syn::Ident) -> syn::Result()?); + Ok(()) + } else if meta.path.is_ident("options") { + let value = meta.value()?; + options = Some(value.parse::()?); + Ok(()) + } else if meta.path.is_ident("multiline") { + let value = meta.value()?; + let lit: syn::LitBool = value.parse()?; + multiline = lit.value; + Ok(()) } else { Err(meta.error(format!( "unsupported #[config(...)] attribute on field `{}`", @@ -160,7 +436,15 @@ fn parse_field_config(attrs: &[Attribute], ident: &syn::Ident) -> syn::Result String { @@ -178,3 +462,14 @@ fn snake_to_camel(snake: &str) -> String { } out } + +/// Convert a snake_case or kebab-case identifier into a Title-Cased label +/// suitable for UI rendering. "max_iterations" -> "Max iterations". +fn humanize(s: &str) -> String { + let normalized = s.replace(['_', '-'], " "); + let mut chars = normalized.chars(); + match chars.next() { + Some(c) => c.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} diff --git a/apps/native/src-tauri/configurable/Cargo.toml b/apps/native/src-tauri/configurable/Cargo.toml index 201761a05..621a6e357 100644 --- a/apps/native/src-tauri/configurable/Cargo.toml +++ b/apps/native/src-tauri/configurable/Cargo.toml @@ -7,8 +7,10 @@ license = "MIT" [dependencies] anyhow = "1" +inventory = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" +specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] } tauri = { version = "2" } tauri-plugin-store = "2" configurable-derive = { path = "../configurable-derive" } diff --git a/apps/native/src-tauri/configurable/src/lib.rs b/apps/native/src-tauri/configurable/src/lib.rs index 0b0f8c9ce..4c028d384 100644 --- a/apps/native/src-tauri/configurable/src/lib.rs +++ b/apps/native/src-tauri/configurable/src/lib.rs @@ -1,32 +1,46 @@ //! Configurable — store-backed dev settings without per-knob boilerplate. //! -//! Derive `Configurable` on a struct to generate a `load(app)` method that reads -//! each field from `tauri-plugin-store`, falling back to the per-field default. -//! Edits made by the user via the dev-settings UI (which writes to the same -//! store keys) are picked up on the next `load(app)` call — i.e. hot-reload. +//! Derive `Configurable` on a struct to: +//! 1. Generate a `load(app)` method that reads each field from +//! `tauri-plugin-store`, falling back to the per-field default. +//! 2. Expose a rich schema (`schema()`) describing every field's +//! type, label, help text, range, and default value. +//! 3. Auto-register the struct in a global `inventory` registry so +//! a single Tauri command can enumerate all configurables and +//! render their UI without per-struct frontend code. //! //! ```ignore //! use configurable::Configurable; //! //! #[derive(Configurable, Debug, Clone)] -//! #[config(store_path = "settings.json")] +//! #[config( +//! store_path = "settings.json", +//! display_name = "Tuning", +//! )] //! pub struct EvolutionLimits { -//! #[config(default = 25, key = "maxIterations")] +//! #[config( +//! default = 25, +//! key = "maxIterations", +//! label = "Max iterations", +//! range = 1..=200, +//! help = "API calls before stopping", +//! )] //! pub max_iterations: usize, -//! -//! #[config(default = 5, key = "maxBuildAttempts")] -//! pub max_build_attempts: usize, //! } //! //! let limits = EvolutionLimits::load(&app)?; +//! let schema = EvolutionLimits::schema(&app)?; // also exposed via the registry //! ``` use anyhow::Result; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use specta::Type; use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; pub use configurable_derive::Configurable; +pub use inventory; /// Read a typed field from the named store, returning `None` when the key is /// absent or the stored JSON fails to deserialize into `T` (e.g. schema drift). @@ -41,3 +55,126 @@ where .get(key) .and_then(|value| serde_json::from_value(value.clone()).ok())) } + +/// Write a single key/value pair to the named store and persist. +pub fn write_field( + app: &AppHandle, + store_path: &str, + key: &str, + value: serde_json::Value, +) -> Result<()> { + let store = app.store(store_path)?; + store.set(key, value); + store.save()?; + Ok(()) +} + +// ============================================================================= +// Schema types — flow to TS via specta +// ============================================================================= + +/// What kind of control should render this field. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum FieldType { + /// Numeric input with optional min/max/step. + Number { + #[serde(skip_serializing_if = "Option::is_none")] + min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + }, + /// Toggle / checkbox. + Boolean, + /// Single-line text or multi-line textarea when `multiline = true`. + String { multiline: bool }, + /// Select / dropdown of pre-declared options. + Enum { variants: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EnumVariant { + pub value: String, + pub label: String, +} + +/// Per-field description rendered into a UI control. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ConfigField { + /// Key as written to the underlying store (typically camelCase). + pub key: String, + /// Human-readable label rendered above the input. + pub label: String, + /// Optional help text (rendered as a tooltip / "info" icon). + #[serde(skip_serializing_if = "Option::is_none")] + pub help: Option, + /// What control to render. + pub ty: FieldType, + /// Default if the store has no value yet. + pub default: serde_json::Value, + /// Current value loaded from the store. + pub current: serde_json::Value, +} + +/// One section in the auto-rendered settings panel — corresponds to one +/// `#[derive(Configurable)]` struct. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ConfigurableSchema { + /// Unique stable identifier (struct's Rust name). Used by `set_field` to + /// dispatch to the right registered configurable. + pub name: String, + /// Title shown above the section in the UI. + pub display_name: String, + /// Optional one-line description shown under the title. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub fields: Vec, +} + +// ============================================================================= +// Registry — auto-populated at startup via inventory::submit! in the derive +// ============================================================================= + +/// Type-erased per-struct shim that the registry can call without knowing the +/// concrete type. The derive generates one of these per `Configurable` struct +/// and submits it to the inventory. +pub struct RegisteredConfig { + /// Stable identifier matching `ConfigurableSchema.name`. + pub name: &'static str, + /// Returns the schema with all fields' `current` values populated. + pub schema_fn: fn(&AppHandle) -> Result, + /// Writes `value` to the store under `key`, with type-checking against + /// the field's declared type. Returns an error if the field doesn't + /// exist on this struct or the value can't be coerced. + pub set_field_fn: fn(&AppHandle, &str, serde_json::Value) -> Result<()>, +} + +inventory::collect!(RegisteredConfig); + +/// Walks the inventory and returns all registered configurable schemas with +/// their current values populated. +pub fn all_schemas(app: &AppHandle) -> Result> { + inventory::iter:: + .into_iter() + .map(|reg| (reg.schema_fn)(app)) + .collect() +} + +/// Find the registered struct by name and delegate the set. +pub fn set_field_by_name( + app: &AppHandle, + struct_name: &str, + field_key: &str, + value: serde_json::Value, +) -> Result<()> { + let reg = inventory::iter:: + .into_iter() + .find(|reg| reg.name == struct_name) + .ok_or_else(|| anyhow::anyhow!("unknown Configurable: {struct_name}"))?; + (reg.set_field_fn)(app, field_key, value) +} diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 4d975a415..aaa656421 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -79,6 +79,10 @@ fn main() { .register::() .register::() .register::() + .register::() + .register::() + .register::() + .register::() .register::() .register::() .register::() diff --git a/apps/native/src-tauri/src/commands/dev_configs.rs b/apps/native/src-tauri/src/commands/dev_configs.rs new file mode 100644 index 000000000..1c39095e9 --- /dev/null +++ b/apps/native/src-tauri/src/commands/dev_configs.rs @@ -0,0 +1,29 @@ +//! Tauri commands that walk the `configurable` inventory registry. +//! +//! Frontend calls `dev_configs_list` to enumerate every `#[derive(Configurable)]` +//! struct in the codebase, get its schema (labels, types, ranges, current +//! values), and render a section per struct. Edits go back through +//! `dev_config_set` which dispatches by struct name to the right shim +//! generated by the derive. + +use super::helpers::capture_err; +use configurable::ConfigurableSchema; +use tauri::AppHandle; + +/// Enumerate every registered Configurable struct with its current values. +#[tauri::command] +pub async fn dev_configs_list(app: AppHandle) -> Result, String> { + configurable::all_schemas(&app).map_err(|e| capture_err("dev_configs_list", e)) +} + +/// Set one field on one Configurable struct, dispatched by struct name. +#[tauri::command] +pub async fn dev_config_set( + app: AppHandle, + struct_name: String, + key: String, + value: serde_json::Value, +) -> Result<(), String> { + configurable::set_field_by_name(&app, &struct_name, &key, value) + .map_err(|e| capture_err("dev_config_set", e)) +} diff --git a/apps/native/src-tauri/src/commands/mod.rs b/apps/native/src-tauri/src/commands/mod.rs index 9f0abe494..1729ac18b 100644 --- a/apps/native/src-tauri/src/commands/mod.rs +++ b/apps/native/src-tauri/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod apply; pub mod cli_tool; pub mod config; pub mod debug; +pub mod dev_configs; pub mod editor; pub mod evolve; pub mod evolve_state; diff --git a/apps/native/src-tauri/src/evolve/config.rs b/apps/native/src-tauri/src/evolve/config.rs index 741f703d1..5a1aa9a65 100644 --- a/apps/native/src-tauri/src/evolve/config.rs +++ b/apps/native/src-tauri/src/evolve/config.rs @@ -3,15 +3,48 @@ //! //! Storage is repo-scoped — values live under `/.nixmac/settings.json` //! so they ride along with the user's nix config repo across machines. +//! +//! The struct also auto-registers with the global Configurable inventory, so +//! the Tuning section in the Developer settings tab renders these fields +//! without any per-field UI code. use configurable::Configurable; #[derive(Configurable, Debug, Clone)] -#[config(store_path_fn = crate::storage::configurable_scope::repo_store_path)] +#[config( + store_path_fn = crate::storage::configurable_scope::repo_store_path, + display_name = "Evolution", + description = "How long the agent will try before giving up.", +)] pub struct EvolutionLimits { - #[config(default = 25, key = "maxIterations")] + #[config( + default = 25, + key = "maxIterations", + label = "Max iterations", + range = 1..=200, + help = "API calls before the agent stops. Lower = faster/cheaper but may not finish complex changes.", + )] pub max_iterations: usize, - #[config(default = 5, key = "maxBuildAttempts")] + #[config( + default = 5, + key = "maxBuildAttempts", + label = "Max build attempts", + range = 1..=20, + help = "Failed builds before giving up on a run.", + )] pub max_build_attempts: usize, } + +// Matches the `#[config(default = ...)]` values above. Used as the fallback +// in evolve::mod when EvolutionLimits::load fails (e.g. config_dir not yet +// set during onboarding); deriving `Default` would produce zeros, which +// would be wrong here. +impl Default for EvolutionLimits { + fn default() -> Self { + Self { + max_iterations: 25, + max_build_attempts: 5, + } + } +} diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 3abf758e6..25e1b23c6 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -530,6 +530,9 @@ fn run_gui_mode( // Settings backup/restore (developer-mode only) commands::settings_io::settings_export, commands::settings_io::settings_import, + // Configurable registry (auto-UI for dev settings) + commands::dev_configs::dev_configs_list, + commands::dev_configs::dev_config_set, // Model cache commands::ui_prefs::get_cached_models, commands::ui_prefs::set_cached_models, diff --git a/apps/native/src/components/widget/settings/__snapshots__/developer-tab.stories.tsx.snap b/apps/native/src/components/widget/settings/__snapshots__/developer-tab.stories.tsx.snap index 54363d27a..ce6b1ea6c 100644 --- a/apps/native/src/components/widget/settings/__snapshots__/developer-tab.stories.tsx.snap +++ b/apps/native/src/components/widget/settings/__snapshots__/developer-tab.stories.tsx.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Pinned To Past Version 1`] = `"

Developer

Hidden tools for debugging and bisecting regressions. Don't use these unless you know what you're doing.

Heads up
Installing a past version replaces your current .app bundle on disk. The version number you enter must match an existing release at releases.nixmac.com. Bisecting only works in release builds — the dev binary doesn't ship the updater plugin.
Update channel

Stable follows releases from main. Develop follows signed release-mode builds from develop. Version pins override the selected channel until you resume auto-update.

Current channel: develop
Version pin
Pinned to v0.21.0. The silent update check on launch is suppressed while pinned.
Install a past release

Enter a version that exists in the release bucket (look at git tag for valid values). The signed bundle is downloaded from releases.nixmac.com/<version>/, verified, and installed.

Tuning

Knobs that control how the evolution loop behaves. Changes take effect on the next run. Saved to .nixmac/settings.json in your config repo so they sync across machines.

Backup & Restore

Export the contents of settings.json (per-device prefs like provider, model, and confirmations) to a file you can keep or share. Import replaces all current settings with the contents of a previously exported file. Repo-synced tuning values live in .nixmac/settings.json inside your config directory and are versioned by git — not included in this export.

State reset

Reset saved Tauri plugin-store data when the widget gets stuck in the wrong step or cached data looks stale. This clears saved settings, routing state, build state, prompt history, and model caches.

"`; +exports[`Pinned To Past Version 1`] = `"

Developer

Hidden tools for debugging and bisecting regressions. Don't use these unless you know what you're doing.

Heads up
Installing a past version replaces your current .app bundle on disk. The version number you enter must match an existing release at releases.nixmac.com. Bisecting only works in release builds — the dev binary doesn't ship the updater plugin.
Update channel

Stable follows releases from main. Develop follows signed release-mode builds from develop. Version pins override the selected channel until you resume auto-update.

Current channel: develop
Version pin
Pinned to v0.21.0. The silent update check on launch is suppressed while pinned.
Install a past release

Enter a version that exists in the release bucket (look at git tag for valid values). The signed bundle is downloaded from releases.nixmac.com/<version>/, verified, and installed.

Tuning

Knobs that control how the evolution loop behaves. Changes take effect on the next run. Saved to .nixmac/settings.json in your config repo so they sync across machines.

Backup & Restore

Export the contents of settings.json (per-device prefs like provider, model, and confirmations) to a file you can keep or share. Import replaces all current settings with the contents of a previously exported file. Repo-synced tuning values live in .nixmac/settings.json inside your config directory and are versioned by git — not included in this export.

State reset

Reset saved Tauri plugin-store data when the widget gets stuck in the wrong step or cached data looks stale. This clears saved settings, routing state, build state, prompt history, and model caches.

"`; -exports[`Unpinned 1`] = `"

Developer

Hidden tools for debugging and bisecting regressions. Don't use these unless you know what you're doing.

Heads up
Installing a past version replaces your current .app bundle on disk. The version number you enter must match an existing release at releases.nixmac.com. Bisecting only works in release builds — the dev binary doesn't ship the updater plugin.
Update channel

Stable follows releases from main. Develop follows signed release-mode builds from develop. Version pins override the selected channel until you resume auto-update.

Current channel: stable
Version pin
Not pinned. Currently running v0.22.0.
Install a past release

Enter a version that exists in the release bucket (look at git tag for valid values). The signed bundle is downloaded from releases.nixmac.com/<version>/, verified, and installed.

Tuning

Knobs that control how the evolution loop behaves. Changes take effect on the next run. Saved to .nixmac/settings.json in your config repo so they sync across machines.

Backup & Restore

Export the contents of settings.json (per-device prefs like provider, model, and confirmations) to a file you can keep or share. Import replaces all current settings with the contents of a previously exported file. Repo-synced tuning values live in .nixmac/settings.json inside your config directory and are versioned by git — not included in this export.

State reset

Reset saved Tauri plugin-store data when the widget gets stuck in the wrong step or cached data looks stale. This clears saved settings, routing state, build state, prompt history, and model caches.

"`; +exports[`Unpinned 1`] = `"

Developer

Hidden tools for debugging and bisecting regressions. Don't use these unless you know what you're doing.

Heads up
Installing a past version replaces your current .app bundle on disk. The version number you enter must match an existing release at releases.nixmac.com. Bisecting only works in release builds — the dev binary doesn't ship the updater plugin.
Update channel

Stable follows releases from main. Develop follows signed release-mode builds from develop. Version pins override the selected channel until you resume auto-update.

Current channel: stable
Version pin
Not pinned. Currently running v0.22.0.
Install a past release

Enter a version that exists in the release bucket (look at git tag for valid values). The signed bundle is downloaded from releases.nixmac.com/<version>/, verified, and installed.

Tuning

Knobs that control how the evolution loop behaves. Changes take effect on the next run. Saved to .nixmac/settings.json in your config repo so they sync across machines.

Backup & Restore

Export the contents of settings.json (per-device prefs like provider, model, and confirmations) to a file you can keep or share. Import replaces all current settings with the contents of a previously exported file. Repo-synced tuning values live in .nixmac/settings.json inside your config directory and are versioned by git — not included in this export.

State reset

Reset saved Tauri plugin-store data when the widget gets stuck in the wrong step or cached data looks stale. This clears saved settings, routing state, build state, prompt history, and model caches.

"`; diff --git a/apps/native/src/components/widget/settings/auto-config-field.tsx b/apps/native/src/components/widget/settings/auto-config-field.tsx new file mode 100644 index 000000000..a62b9c566 --- /dev/null +++ b/apps/native/src/components/widget/settings/auto-config-field.tsx @@ -0,0 +1,173 @@ +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { tauriAPI } from "@/ipc/api"; +import type { ConfigField } from "@/ipc/types"; +import { Info } from "lucide-react"; +import { useState } from "react"; + +interface Props { + /** Stable identifier of the Configurable struct this field belongs to. */ + structName: string; + /** Field metadata + current value, sourced from the backend registry. */ + field: ConfigField; + /** Called after a successful save with the new value so the parent can + * refresh the schema or surface a status message. Optional. */ + onSaved?: (key: string, value: unknown) => void; +} + +/** + * Renders the appropriate control for a `ConfigField` based on `field.ty.kind` + * and writes changes back through `tauriAPI.devConfigs.set`. Local optimistic + * state keeps the input snappy while the backend persists. On error, reverts + * and surfaces the message inline. + */ +export function AutoConfigField({ structName, field, onSaved }: Props) { + const [value, setValue] = useState(field.current); + const [error, setError] = useState(null); + + const commit = async (next: unknown) => { + const previous = value; + setValue(next); + setError(null); + try { + await tauriAPI.devConfigs.set(structName, field.key, next); + onSaved?.(field.key, next); + } catch (e) { + setValue(previous); + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const labelRow = ( +
+ + {field.help && ( + + + + + + {field.help} + + + )} +
+ ); + + const errorRow = error &&

{error}

; + + switch (field.ty.kind) { + case "number": { + const numericValue = typeof value === "number" ? value : Number(value ?? 0); + return ( +
+ {labelRow} + { + // Update local state immediately for responsiveness; commit on blur. + const next = Number.parseFloat(e.target.value); + setValue(Number.isFinite(next) ? next : e.target.value); + }} + onBlur={() => { + const next = Number.parseFloat(String(value)); + if (Number.isFinite(next)) commit(next); + }} + /> + {errorRow} +
+ ); + } + case "boolean": { + const checked = Boolean(value); + return ( +
+ {labelRow} + commit(next)} + /> + {errorRow} +
+ ); + } + case "string": { + const stringValue = typeof value === "string" ? value : ""; + if (field.ty.multiline) { + return ( +
+ {labelRow} +