diff --git a/.omo/plans/flag-experiment-master-plan.md b/.omo/plans/flag-experiment-master-plan.md new file mode 100644 index 000000000..84c7e6e0d --- /dev/null +++ b/.omo/plans/flag-experiment-master-plan.md @@ -0,0 +1,1144 @@ +# Implementation Plan: Experiment Flag System +> Generated from Codex deep-dive research + jcode codebase integration analysis +> Goal: A lifecycle-managed experiment flag system (inspired by Codex `codex_features`) for gradual, safe rollout of new features in jcode + +--- + +## 1. Executive Summary + +We will implement a centralized experiment flag system for jcode modeled after Codex's `codex_features` crate. The system introduces an `ExperimentFlag` enum with lifecycle stages (UnderDevelopment → Experimental → Stable → Deprecated → Removed), a TOML-based `[experiments]` config section for user toggles, CLI subcommands (`jcode experiment list/enable/disable`), runtime protocol API for TUI clients, and a checkbox list TUI popup. The system integrates into jcode's existing config layer (crates/jcode-config-types), CLI dispatch (src/cli/), protocol (crates/jcode-protocol), and TUI (crates/jcode-tui). This is a new `jcode-experiment-flags` crate plus modifications to 6 existing crates/modules. + +--- + +## 2. Architecture Decision + +### Chosen Approach + +**Codex-style centralized enum + Stage lifecycle + TOML config**, adapted for jcode's existing `FeatureConfig`: + +| Aspect | Decision | Why | +|--------|----------|-----| +| Core pattern | `ExperimentFlag` enum + `FEATURES` static `&[FeatureSpec]` | Codex proven pattern in Rust, compile-time enum safety | +| Stage lifecycle | UnderDevelopment → Experimental → Stable → Deprecated → Removed | Codex pattern, matches semantic versioning philosophy | +| Config storage | `[experiments]` TOML section alongside existing `[features]` | jcode already has `FeatureConfig` for mature toggles; experiments are separate | +| Config persistence | Written back to `config.toml` via existing config save path | Follows existing `jcode config` save mechanism | +| CLI | `jcode experiment list/enable/disable` | Mirror of `SkillsCommand` pattern (exists in jcode) | +| Protocol | `ExperimentFlagList` request/response + `ExperimentFlagEnablementSet` | NDJSON over Unix sockets, same pattern as existing wire types | +| TUI | Modal popup with checkbox list (spacebar toggle) | Follows `OverlayAction`+`session_picker` patterns already in jcode-tui | +| Runtime check | `experiments.check(ExperimentFlag::X)` returning `bool` | Similar to Codex `features.enabled(Feature::X)` | +| Dependencies | Auto-enable required flags (optional) | Codex `normalize_dependencies()` pattern | +| Enterprise constraints | Optional pinned flag overrides via TOML | Codex `FeatureRequirementsToml` / `pinned_features` pattern | + +### Alternatives Considered + +| Approach | Source | Pros | Cons | Decision | +|----------|--------|------|------|----------| +| Centralized `Feature` enum + `Stage` | Codex `codex_features` | Compile-time safety, discoverable, one registry | Requires new crate, enum changes need recompile | ✅ Chosen | +| Individual `feature('NAME')` strings | Claude Code Vite plugin | Zero boilerplate to add | No type safety, no lifecycle, runtime errors | ❌ Strings are fragile | +| GrowthBook remote flags | Claude Code | Remote toggle without deploy | Requires server, latency, single point of failure | ❌ Too heavy for CLI tool | +| Env vars only | oh-my-pi | Simplest implementation | No lifecycle, no discoverability, no TUI | ❌ Not sufficient | +| Extend existing `FeatureConfig` | jcode current | No new crate, minimal change | Current `FeatureConfig` is fixed struct fields, not dynamic | ❌ Hard to add new flags without recompile of base crate | + +--- + +## 3. Data Structures & Types + +### Core Types (new crate: `crates/jcode-experiment-flags`) + +```rust +// ============================================================================ +// File: crates/jcode-experiment-flags/src/lib.rs +// ============================================================================ + +/// Lifecycle stage of an experiment flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + /// Internal-only, not stable enough for users. Emits warning when enabled. + UnderDevelopment, + /// Ready for early adopters. Visible in `jcode experiment list` TUI popup. + /// Shows in `/experimental` command menu with name + description. + Experimental { + name: &'static str, + menu_description: &'static str, + /// A one-line announcement message shown when the flag becomes experimental. + announcement: Option<&'static str>, + }, + /// Stable and enabled by default. No longer shown in experiment list. + Stable, + /// Still works but scheduled for removal. Emits deprecation warning. + Deprecated { + /// Message explaining what to use instead. + migration_hint: &'static str, + }, + /// Removed. Flag still parsed for config backwards compat but always evaluates to false. + Removed, +} + +/// Unique identifier for each experiment flag (enum-based, like Codex). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter, strum::Display)] +#[strum(serialize_all = "snake_case")] +pub enum ExperimentFlag { + /// Dynamic Context Pruning (currently in FeatureConfig::dcp_enabled) + DynamicContextPruning, + /// Swarm coordination (currently in FeatureConfig::swarm) + SwarmCoordination, + /// V2 Hooks system (28 events, parallel dispatch) + HooksV2, + /// JavaScript plugin runtime (QuickJS embedded) + JsPlugins, + /// Persistent memory injection + PersistMemoryInjection, + /// Reasoning trace display in TUI + ReasoningTrace, + // ... more flags added over time +} + +/// Static specification for an experiment flag — single source of truth. +#[derive(Debug, Clone, Copy)] +pub struct FeatureSpec { + pub id: ExperimentFlag, + /// TOML key name (e.g., "hooks_v2"). + pub key: &'static str, + /// Current lifecycle stage. + pub stage: Stage, + /// Whether the flag defaults to enabled. + pub default_enabled: bool, + /// Feature IDs that must also be enabled for this flag to work. + pub dependencies: &'static [ExperimentFlag], +} + +/// All experiment flags defined in the system. +pub static EXPERIMENT_FLAGS: &[FeatureSpec] = &[ + FeatureSpec { + id: ExperimentFlag::DynamicContextPruning, + key: "dcp_enabled", + stage: Stage::Stable, + default_enabled: true, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::SwarmCoordination, + key: "swarm", + stage: Stage::Stable, + default_enabled: true, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::HooksV2, + key: "hooks_v2", + stage: Stage::UnderDevelopment, + default_enabled: false, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::JsPlugins, + key: "js_plugins", + stage: Stage::UnderDevelopment, + default_enabled: false, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::PersistMemoryInjection, + key: "persist_memory_injections", + stage: Stage::Experimental { + name: "Persist Memory Injections", + menu_description: "Persist auto-recalled memory injections into normal session history", + announcement: None, + }, + default_enabled: false, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::ReasoningTrace, + key: "reasoning_trace", + stage: Stage::Experimental { + name: "Reasoning Trace", + menu_description: "Show model reasoning trace in TUI output", + announcement: Some("Reasoning traces now available in TUI — /experimental to enable"), + }, + default_enabled: false, + dependencies: &[], + }, +]; + +/// Runtime representation of flag enablement state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Experiments { + /// The set of enabled experiment flags. + enabled: BTreeSet, + /// Tracking for deprecated/renamed flag usages. + #[serde(skip)] + legacy_usages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegacyUsage { + pub key: String, + pub resolved_to: ExperimentFlag, + pub count: u64, +} + +impl Experiments { + /// Create with default-enabled flags. + pub fn with_defaults() -> Self { + let mut enabled = BTreeSet::new(); + for spec in EXPERIMENT_FLAGS { + if spec.default_enabled { + enabled.insert(spec.id); + } + } + Self { + enabled, + legacy_usages: Vec::new(), + } + } + + /// Check if a flag is enabled. + pub fn check(&self, flag: ExperimentFlag) -> bool { + self.enabled.contains(&flag) + } + + /// Enable a flag. + pub fn enable(&mut self, flag: ExperimentFlag) { + self.enabled.insert(flag); + } + + /// Disable a flag. + pub fn disable(&mut self, flag: ExperimentFlag) { + self.enabled.remove(&flag); + } + + /// Set flag state from the provided map. + pub fn apply_map(&mut self, map: &BTreeMap) { + for (key, value) in map { + if let Some(flag) = self.resolve_key(key) { + if value { + self.enabled.insert(flag); + } else { + self.enabled.remove(&flag); + } + } + } + } + + /// Resolve a string key to an ExperimentFlag (with legacy support). + fn resolve_key(&self, key: &str) -> Option { + // First try direct match + for spec in EXPERIMENT_FLAGS { + if spec.key == key { + return Some(spec.id); + } + } + // Try legacy/renamed keys + match key { + "memory" => Some(ExperimentFlag::DynamicContextPruning), + "collab" => Some(ExperimentFlag::SwarmCoordination), + _ => None, + } + } + + /// Normalize dependencies: auto-enable required flags. + pub fn normalize_dependencies(&mut self) { + let mut changed = true; + while changed { + changed = false; + for spec in EXPERIMENT_FLAGS { + if self.enabled.contains(&spec.id) { + for dep in spec.dependencies { + if self.enabled.insert(*dep) { + changed = true; + } + } + } + } + } + } + + /// Get all flags that are currently enabled. + pub fn enabled_flags(&self) -> Vec { + self.enabled.iter().copied().collect() + } + + /// Get the list of all flags with their current state, for TUI/CLI display. + pub fn all_flag_states(&self) -> Vec { + EXPERIMENT_FLAGS + .iter() + .map(|spec| FlagState { + flag: spec.id, + key: spec.key, + stage: spec.stage, + enabled: self.enabled.contains(&spec.id), + default_enabled: spec.default_enabled, + }) + .collect() + } +} + +/// Display state for one experiment flag. +#[derive(Debug, Clone, Serialize)] +pub struct FlagState { + pub flag: ExperimentFlag, + pub key: &'static str, + pub stage: Stage, + pub enabled: bool, + pub default_enabled: bool, +} + +// ============================================================================ +// File: crates/jcode-experiment-flags/src/toml.rs +// ============================================================================ + +/// TOML deserialization of the [experiments] section. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ExperimentsToml { + /// Flattened key-value pairs, e.g. hooks_v2 = true + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl ExperimentsToml { + /// Materialize into Experiments struct. + pub fn materialize(&self) -> Experiments { + let mut experiments = Experiments::with_defaults(); + experiments.apply_map(&self.entries); + experiments.normalize_dependencies(); + experiments + } +} + +// ============================================================================ +// File: crates/jcode-experiment-flags/src/tui.rs +// ============================================================================ + +/// Flag information for TUI display (one row in the experimental features popup). +#[derive(Debug, Clone)] +pub struct ExperimentFlagInfo { + pub flag: ExperimentFlag, + pub key: String, + pub name: String, + pub description: String, + pub stage: Stage, + pub enabled: bool, +} +``` + +### Config Integration + +```rust +// File: crates/jcode-config-types/src/lib.rs (MODIFIED) + +/// Runtime feature toggles (existing — stays for stable features) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct FeatureConfig { + /// Enable memory retrieval/extraction features (default: true) + pub memory: bool, + /// Enable swarm coordination features (default: true) + pub swarm: bool, + /// Inject timestamps into user messages and tool results (default: true) + pub message_timestamps: bool, + /// Persist auto-recalled memory injections (default: false) + pub persist_memory_injections: bool, + // NOTE: dcp_enabled and update_channel move to experiments +} + +/// NEW: Experiment flags section +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ExperimentConfig { + /// Enable hooks v2 system + #[serde(default)] + pub hooks_v2: Option, + /// Enable JS plugin runtime + #[serde(default)] + pub js_plugins: Option, + /// Enable reasoning trace display + #[serde(default)] + pub reasoning_trace: Option, + /// Catch-all for unknown experiment flags (parsed from flatten) + #[serde(flatten, skip_serializing)] + pub extra: BTreeMap, +} +``` + +Wait — to match Codex's dynamic approach and avoid modifying this struct every time we add a flag, we should use the `BTreeMap` approach directly, not individual `Option` fields. This matches Codex's `FeaturesToml` with `#[serde(flatten)] entries: BTreeMap`. + +```rust +// File: crates/jcode-config-types/src/lib.rs (REVISED) + +/// Experiment flags TOML section — dynamically keyed. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ExperimentConfig { + /// Dynamic experiment flag entries, e.g.: + /// [experiments] + /// hooks_v2 = true + /// js_plugins = false + #[serde(flatten)] + pub entries: BTreeMap, +} +``` + +--- + +## 4. Pseudocode — Core Algorithm + +``` +// Initialization +FUNCTION init_experiments(config_toml): + // 1. Load [experiments] section from config.toml (dynamically keyed) + // 2. Materialize into Experiments struct with defaults from EXPERIMENT_FLAGS static + // 3. Apply user overrides from config + // 4. Normalize dependencies (auto-enable required flags) + // 5. Validate: warn if UnderDevelopment flags are enabled + // 6. Return Experiments instance as singleton + +// Runtime checks +FUNCTION check_flag(experiments, flag): + // 1. Look up flag in enabled set + // 2. Check pinned constraints (enterprise overrides) + // 3. Return boolean + +// CLI: jcode experiment list +FUNCTION cmd_experiment_list(experiments, json): + flags = experiments.all_flag_states() + if json: + print JSON of flags + else: + print table: Key | Stage | Default | Current + highlight UnderDevelopment with WARN label + +// CLI: jcode experiment enable +FUNCTION cmd_experiment_enable(experiments, key): + flag = resolve_key(key) + if flag.stage == Removed: + print error "Flag removed: use X instead" + return + experiments.enable(flag) + experiments.normalize_dependencies() + save config to disk + trigger on_config_reloaded() callbacks + +// CLI: jcode experiment disable +FUNCTION cmd_experiment_disable(experiments, key): + flag = resolve_key(key) + experiments.disable(flag) + save config to disk + trigger on_config_reloaded() callbacks + +// TUI popup +FUNCTION show_experiment_popup(app_state): + // 1. Open modal overlay in center of screen + // 2. Fetch current flag states from server via protocol + // 3. Render checkbox list: + // [x] hooks_v2 — V2 Hooks (HooksV2) [Experimental] + // [ ] js_plugins — JS Plugin Runtime [UnderDevelopment] + // [x] reasoning_trace — Reasoning Trace [Experimental] + // 4. Handle input: + // Space: toggle selected flag + // j/k: navigate + // Enter: apply and close + // Esc: close without changes + // 5. On apply: send ExperimentFlagEnablementSet to server + // 6. Server saves config, notifies clients +``` + +--- + +## 5. Implementation Code + +### Cargo workspace changes + +```toml +# File: Cargo.toml (workspace root — ADD member) +members = [ + ... + "crates/jcode-experiment-flags", + ... +] +``` + +### Module tree for `crates/jcode-experiment-flags` + +``` +crates/jcode-experiment-flags/ +├── Cargo.toml +└── src/ + ├── lib.rs # ExperimentFlag enum, FeatureSpec, FEATURES static, Experiments struct + ├── toml.rs # ExperimentsToml serde deserialization + └── legacy.rs # Legacy key resolution (renamed/removed flags) +``` + +### Cargo.toml + +```toml +# File: crates/jcode-experiment-flags/Cargo.toml +[package] +name = "jcode-experiment-flags" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +strum = { workspace = true, features = ["derive"] } +``` + +### Full Implementation + +```rust +// File: crates/jcode-experiment-flags/src/lib.rs + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +// --- Core Types (shown above in Section 3) --- +// ExperimentFlag enum, Stage enum, FeatureSpec struct, EXPERIMENT_FLAGS static, +// Experiments struct with all methods, FlagState struct + +// --- Key Methods Implementation --- + +impl Experiments { + /// Apply user overrides and validate, emitting warnings for unstable flags. + pub fn from_config(toml_entries: &BTreeMap) -> Self { + let mut ex = Experiments::with_defaults(); + ex.apply_map(toml_entries); + ex.normalize_dependencies(); + ex.warn_unstable(); + ex + } + + fn warn_unstable(&self) { + for spec in EXPERIMENT_FLAGS { + if matches!(spec.stage, Stage::UnderDevelopment) && self.enabled.contains(&spec.id) { + eprintln!( + "[jcode] WARNING: UnderDevelopment flag '{}' is enabled. \ + This feature is not ready for production use.", + spec.key + ); + } + } + } +} +``` + +### jcode Config Integration + +```rust +// File: crates/jcode-config-types/src/lib.rs (MODIFIED) + +/// Experiment flags section in config.toml +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ExperimentConfig { + /// Dynamic experiment flag entries + #[serde(flatten)] + pub entries: BTreeMap, +} +``` + +```rust +// File: crates/jcode-base/src/config.rs (MODIFIED — add section to Config struct) + +pub struct Config { + // ... existing fields ... + pub features: FeatureConfig, + /// Experiment flags section + #[serde(default)] + pub experiments: ExperimentConfig, + // ... remaining fields ... +} +``` + +### CLI Integration + +```rust +// File: src/cli/args.rs (MODIFIED — add FeaturesCommand variant) + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + // ... existing variants ... + + /// Manage experiment flags (list, enable, disable) + #[command(subcommand)] + Experiment(ExperimentCommand), +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ExperimentCommand { + /// List all experiment flags and their current state + List { + /// Emit JSON instead of human-readable output + #[arg(long)] + json: bool, + }, + + /// Enable an experiment flag by key name + Enable { + /// Experiment flag key (e.g., "hooks_v2", "js_plugins") + key: String, + }, + + /// Disable an experiment flag by key name + Disable { + /// Experiment flag key (e.g., "hooks_v2", "js_plugins") + key: String, + }, +} +``` + +```rust +// File: src/cli/dispatch.rs (MODIFIED — add dispatch arm) + +use crate::experiment_flags; // new module + +Some(Command::Experiment(subcmd)) => match subcmd { + ExperimentCommand::List { json } => { + experiment_flags::run_experiment_list_command(json)?; + } + ExperimentCommand::Enable { key } => { + experiment_flags::run_experiment_enable_command(&key)?; + } + ExperimentCommand::Disable { key } => { + experiment_flags::run_experiment_disable_command(&key)?; + } +}, +``` + +```rust +// File: src/cli/experiment_flags.rs (NEW) + +use anyhow::{Context, Result}; +use jcode_experiment_flags::{ExperimentFlag, Experiments, EXPERIMENT_FLAGS}; +use std::collections::BTreeMap; + +/// Flag display states (UnderDevelopment, Experimental, Stable, Deprecated, Removed) +const STAGE_LABEL: &[&str] = &[ + "UnderDevelopment", + "Experimental", + "Stable", + "Deprecated", + "Removed", +]; + +pub fn run_experiment_list_command(json: bool) -> Result<()> { + let config = crate::config::config(); + let experiments = Experiments::from_config(&config.experiments.entries); + + if json { + let states = experiments.all_flag_states(); + println!("{}", serde_json::to_string_pretty(&states)?); + } else { + println!("{:25} {:20} {:8} {:8} {}", "Key", "Flag", "Default", "Current", "Stage"); + println!("{}", "-".repeat(90)); + for spec in EXPERIMENT_FLAGS { + let enabled = experiments.check(spec.id); + let default_str = if spec.default_enabled { "on" } else { "off" }; + let current_str = if enabled { "ON" } else { "OFF" }; + let stage_label = match spec.stage { + Stage::UnderDevelopment => format!("{:20}", "UnderDevelopment"), + Stage::Experimental { name, .. } => format!("{:20}", "Experimental"), + Stage::Stable => format!("{:20}", "Stable"), + Stage::Deprecated { .. } => format!("{:20}", "Deprecated"), + Stage::Removed => format!("{:20}", "Removed"), + }; + println!( + "{:25} {:20} {:8} {:8} {}", + spec.key, + format!("{:?}", spec.id), + default_str, + current_str, + stage_label + ); + } + } + Ok(()) +} + +pub fn run_experiment_enable_command(key: &str) -> Result<()> { + let mut config = crate::config::config(); + config.experiments.entries.insert(key.to_string(), true); + crate::config::save_config(&config)?; + crate::config::invalidate_config_cache(); + eprintln!("[jcode] Experiment '{key}' enabled."); + Ok(()) +} + +pub fn run_experiment_disable_command(key: &str) -> Result<()> { + let mut config = crate::config::config(); + config.experiments.entries.insert(key.to_string(), false); + crate::config::save_config(&config)?; + crate::config::invalidate_config_cache(); + eprintln!("[jcode] Experiment '{key}' disabled."); + Ok(()) +} +``` + +### Protocol Integration + +```rust +// File: crates/jcode-protocol/src/wire.rs (MODIFIED) + +// Add to Request enum: +#[serde(rename = "experiment_list")] +ExperimentList { id: u64 }, + +#[serde(rename = "experiment_set")] +ExperimentSet { id: u64, key: String, enabled: bool }, + +// Add to ServerEvent enum: +#[serde(rename = "experiment_flags")] +ExperimentFlags { + /// JSON array of FlagState objects + flags: Vec, +}, +``` + +### TUI Integration + +```rust +// File: crates/jcode-tui/src/tui/experiment_flags.rs (NEW) + +use ratatui::{ + prelude::*, + widgets::{Block, Borders, List, ListItem, Paragraph}, +}; + +pub struct ExperimentFlagsPopup { + flags: Vec, + selected: usize, + dirty: bool, +} + +impl ExperimentFlagsPopup { + pub fn new(flags: Vec) -> Self { + Self { flags, selected: 0, dirty: false } + } + + pub fn render(&self, frame: &mut Frame, area: Rect) { + // Render checkbox list overlay + // [x] hooks_v2 — V2 Hook System [Experimental] + // [ ] js_plugins — JS Plugin Runtime [UnderDevelopment] + // ... + } + + pub fn handle_key(&mut self, key: KeyCode) -> OverlayAction { + match key { + KeyCode::Down | KeyCode::Char('j') => { + self.selected = (self.selected + 1).min(self.flags.len() - 1); + OverlayAction::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + OverlayAction::Continue + } + KeyCode::Char(' ') => { + if let Some(flag) = self.flags.get_mut(self.selected) { + flag.enabled = !flag.enabled; + self.dirty = true; + } + OverlayAction::Continue + } + KeyCode::Esc => { + if self.dirty { + // TODO: send ExperimentSet to server + } + OverlayAction::Close + } + _ => OverlayAction::Continue, + } + } +} +``` + +### Runtime Check Points + +Key places in jcode where `experiments.check(Flag::X)` gates are needed: + +```rust +// File: crates/jcode-app-core/src/dcg_bridge.rs (example) +use jcode_experiment_flags::{ExperimentFlag, Experiments}; + +// Before triggering DCP: +if experiments.check(ExperimentFlag::DynamicContextPruning) { + // run DCP logic +} + +// File: crates/jcode-tui/src/tui/app/turn.rs (example) +// Before showing reasoning trace: +if experiments.check(ExperimentFlag::ReasoningTrace) { + // render reasoning content +} +``` + +--- + +## 6. Configuration & Wiring + +### Config.toml format + +```toml +# ~/.jcode/config.toml + +[features] +# Stable feature toggles (existing) +memory = true +swarm = true +message_timestamps = true +persist_memory_injections = false + +[experiments] +# Experiment flags — lifecycle managed, dynamic keys +# UnderDevelopment: warn when enabled, not in TUI +# Experimental: show in "jcode experiment list" and TUI popup +# Stable: enabled by default, not shown in experiment views +# Deprecated: warn on use, scheduled for removal +# Removed: parsed for backwards compat, always evaluates to false + +# hooks_v2 = true +# js_plugins = false +# reasoning_trace = true +``` + +### Env var overrides + +```bash +# JCODE_EXPERIMENTS can override any experiment flag at runtime +# Format: comma-separated key=value pairs +# Priority: env var > config file > defaults +JCODE_EXPERIMENTS="hooks_v2=true,js_plugins=true" jcode serve + +# Individual flag env vars (higher priority) +JCODE_HOOKS_V2=true JCODE_JS_PLUGINS=true jcode serve +``` + +### Init flow + +``` +jcode startup + → config::load() reads config.toml + → experiments: ExperimentConfig (BTreeMap) + → Experiments::from_config(&config.experiments.entries) + → load defaults from EXPERIMENT_FLAGS static + → apply user overrides + → normalize dependencies + → warn on UnderDevelopment flags + → store as singleton (or alongside existing config singleton) + → subsystems call experiments.check(Flag::X) at runtime +``` + +--- + +## 7. Repo References + +| Feature Aspect | Repo | File | Link | +|----------------|------|------|------| +| Feature enum (~80 variants) | codex | codex_features/src/feature.rs | https://github.com/openai/codex/blob/main/codex_features/src/feature.rs | +| Stage enum lifecycle | codex | codex_features/src/feature.rs | https://github.com/openai/codex/blob/main/codex_features/src/feature.rs#L45 | +| FEATURES static array | codex | codex_features/src/feature.rs | https://github.com/openai/codex/blob/main/codex_features/src/feature.rs#L93 | +| Features struct with methods | codex | codex_features/src/features.rs | https://github.com/openai/codex/blob/main/codex_features/src/features.rs | +| FeaturesToml flatten pattern | codex | codex_features/src/features_toml.rs | https://github.com/openai/codex/blob/main/codex_features/src/features_toml.rs | +| ManagedFeatures + pinned constraints | codex | codex/src/config/managed_features.rs | https://github.com/openai/codex/blob/main/codex/src/config/managed_features.rs | +| normalize_dependencies | codex | codex_features/src/features.rs | https://github.com/openai/codex/blob/main/codex_features/src/features.rs | +| CLI subcommands (list/enable/disable) | codex | codex/src/bin/cli/main.rs | https://github.com/openai/codex/blob/main/codex/src/bin/cli/main.rs | +| ExperimentalFeaturesView TUI popup | codex | codex_tui/src/view/experimental.rs | https://github.com/openai/codex/blob/main/codex_tui/src/view/experimental.rs | +| Protocol API request/response | codex | codex_core/src/protocol/types.rs | https://github.com/openai/codex/blob/main/codex_core/src/protocol/types.rs | +| Legacy key resolution | codex | codex_features/src/legacy.rs | https://github.com/openai/codex/blob/main/codex_features/src/legacy.rs | +| FeatureConfig in jcode (existing) | jcode | crates/jcode-config-types/src/lib.rs | Line 633 | +| Config struct in jcode | jcode | crates/jcode-base/src/config.rs | Line 377 | +| CLI dispatch in jcode | jcode | src/cli/dispatch.rs | Line 58 | +| Command enum in jcode | jcode | src/cli/args.rs | Line 272 | +| SkillsCommand pattern | jcode | src/cli/args.rs | Line 1058 | +| Protocol Request enum | jcode | crates/jcode-protocol/src/wire.rs | Line 13 | +| Protocol ServerEvent enum | jcode | crates/jcode-protocol/src/wire.rs | Line 585 | +| TUI overlays (changelog) | jcode | crates/jcode-tui/src/tui/ui_overlays.rs | Line 13 | +| TUI session picker (checkbox list) | jcode | crates/jcode-tui/src/tui/session_picker.rs | Line 59 | +| TUI OverlayAction pattern | jcode | crates/jcode-tui/src/tui/session_picker.rs | Line 59 | +| Claude Code build-time flags | claude-code | packages/claude-code/src/feature/ | https://github.com/claude-code-best/claude-code | + +--- + +## 8. Test Cases + +### Unit Tests (for `jcode-experiment-flags` crate) + +```rust +// File: crates/jcode-experiment-flags/src/tests.rs + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults_stable_enabled() { + let ex = Experiments::with_defaults(); + // Stable flags are enabled by default + assert!(ex.check(ExperimentFlag::DynamicContextPruning)); + assert!(ex.check(ExperimentFlag::SwarmCoordination)); + // UnderDevelopment flags are disabled by default + assert!(!ex.check(ExperimentFlag::HooksV2)); + assert!(!ex.check(ExperimentFlag::JsPlugins)); + } + + #[test] + fn test_enable_flag() { + let mut ex = Experiments::with_defaults(); + ex.enable(ExperimentFlag::HooksV2); + assert!(ex.check(ExperimentFlag::HooksV2)); + } + + #[test] + fn test_disable_flag() { + let mut ex = Experiments::with_defaults(); + ex.disable(ExperimentFlag::DynamicContextPruning); + assert!(!ex.check(ExperimentFlag::DynamicContextPruning)); + } + + #[test] + fn test_apply_map() { + let mut ex = Experiments::with_defaults(); + let mut map = BTreeMap::new(); + map.insert("hooks_v2".to_string(), true); + map.insert("dcp_enabled".to_string(), false); + ex.apply_map(&map); + assert!(ex.check(ExperimentFlag::HooksV2)); + assert!(!ex.check(ExperimentFlag::DynamicContextPruning)); + } + + #[test] + fn test_dependency_normalization() { + // If we add dependency relationships, test they get auto-enabled + let mut ex = Experiments::with_defaults(); + ex.normalize_dependencies(); + // No-op if no deps enabled — just verify it runs + } + + #[test] + fn test_legacy_key_resolution() { + let mut ex = Experiments::with_defaults(); + let mut map = BTreeMap::new(); + map.insert("memory".to_string(), false); + ex.apply_map(&map); + assert!(!ex.check(ExperimentFlag::DynamicContextPruning)); + } + + #[test] + fn test_removed_flag_always_false() { + // Removed flags should always evaluate to false + // (test with a hypothetical removed flag scenario) + let ex = Experiments::with_defaults(); + // ... assert removed flags are handled gracefully + } + + #[test] + fn test_all_flag_states_length() { + let ex = Experiments::with_defaults(); + let states = ex.all_flag_states(); + assert_eq!(states.len(), EXPERIMENT_FLAGS.len()); + } + + #[test] + fn test_serialization_roundtrip() { + let mut ex = Experiments::with_defaults(); + ex.enable(ExperimentFlag::HooksV2); + let json = serde_json::to_string(&ex).unwrap(); + let deserialized: Experiments = serde_json::from_str(&json).unwrap(); + assert!(deserialized.check(ExperimentFlag::HooksV2)); + } + + #[test] + fn test_toml_deserialization() { + let toml_str = r#" + [experiments] + hooks_v2 = true + js_plugins = false + "#; + let config: ExperimentConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.entries.get("hooks_v2"), Some(&true)); + assert_eq!(config.entries.get("js_plugins"), Some(&false)); + } +} +``` + +### Integration Tests + +```rust +// File: src/cli/tests/experiment_tests.rs + +#[cfg(test)] +mod tests { + use assert_cmd::Command; + + #[test] + fn test_experiment_list_command() { + // jcode experiment list + let mut cmd = Command::cargo_bin("jcode").unwrap(); + cmd.arg("experiment").arg("list"); + cmd.assert().success(); + } + + #[test] + fn test_experiment_enable() { + // jcode experiment enable hooks_v2 + let mut cmd = Command::cargo_bin("jcode").unwrap(); + cmd.arg("experiment").arg("enable").arg("hooks_v2"); + cmd.assert().success(); + } + + #[test] + fn test_experiment_disable() { + // jcode experiment disable hooks_v2 + let mut cmd = Command::cargo_bin("jcode").unwrap(); + cmd.arg("experiment").arg("disable").arg("hooks_v2"); + cmd.assert().success(); + } + + #[test] + fn test_experiment_list_json() { + let mut cmd = Command::cargo_bin("jcode").unwrap(); + cmd.arg("experiment").arg("list").arg("--json"); + cmd.assert().success().stdout(predicates::str::contains("flag")); + } + + #[test] + fn test_experiment_unknown_flag() { + // Should handle gracefully + let mut cmd = Command::cargo_bin("jcode").unwrap(); + cmd.arg("experiment").arg("enable").arg("nonexistent_flag"); + // Might still succeed (just inserts into BTreeMap) — depends on validation + } +} +``` + +### E2E Test + +```rust +#[tokio::test] +async fn test_experiment_flag_lifecycle() { + // 1. Start server with experiments config + // 2. Verify defaults + // 3. Send ExperimentSet via protocol + // 4. Verify flag is now enabled + // 5. Check that gated code path activates + // 6. Disable flag + // 7. Verify gated code path deactivates +} +``` + +--- + +## 9. Benchmarks + +### What to Measure + +| Metric | Baseline | Target | How to Measure | +|--------|----------|--------|----------------| +| `Experiments::check()` latency | N/A (new) | <100ns | `criterion` benchmark on `experiments.check(Flag::X)` | +| `Experiments::from_config()` latency | N/A (new) | <50µs | `criterion` on loading 20-50 flags | +| Memory delta per instance | N/A (new) | <2KB | `dhat` heap profiler on `Experiments` struct | +| Config load time increase | ~200µs | +50µs max | Instrument `config::load()` with tracing | + +### Benchmark Code + +```rust +#[cfg(test)] +mod benchmarks { + use super::*; + use criterion::{black_box, criterion_group, criterion_main, Criterion}; + + fn bench_check_flag(c: &mut Criterion) { + let ex = Experiments::with_defaults(); + c.bench_function("check_flag", |b| { + b.iter(|| { + black_box(ex.check(ExperimentFlag::DynamicContextPruning)); + }) + }); + } + + fn bench_from_config(c: &mut Criterion) { + let mut map = BTreeMap::new(); + map.insert("hooks_v2".to_string(), true); + map.insert("js_plugins".to_string(), false); + c.bench_function("from_config_20_flags", |b| { + b.iter(|| { + black_box(Experiments::from_config(&map)); + }) + }); + } + + criterion_group!(benches, bench_check_flag, bench_from_config); + criterion_main!(benches); +} +``` + +--- + +## 10. Migration / Rollout + +### Phase 1: Foundation (Day 1-2) +- Create `jcode-experiment-flags` crate with `ExperimentFlag` enum, `EXPERIMENT_FLAGS` static, `Experiments` struct +- Add `[experiments]` section to config (behind `ExperimentConfig`) +- Wire into config load + store lifecycle +- **No user-visible changes yet** + +### Phase 2: CLI + Protocol (Day 3-4) +- Add `jcode experiment list/enable/disable` CLI subcommands +- Add `ExperimentList` / `ExperimentSet` protocol requests +- Add `ExperimentFlags` server event +- **Users can now `jcode experiment list`** + +### Phase 3: TUI (Day 5-6) +- Build `ExperimentFlagsPopup` overlay with checkbox list +- Wire into app event handling (keyboard shortcuts) +- On apply: send protocol request to persist +- **Users can toggle flags from `/experimental` TUI menu** + +### Phase 4: Gate Integration (Ongoing) +- Replace `FeatureConfig::dcp_enabled` → `ExperimentFlag::DynamicContextPruning` +- Add `ExperimentFlag::HooksV2` checks in agent turn loop +- Add `ExperimentFlag::JsPlugins` checks in startup +- Add `ExperimentFlag::ReasoningTrace` in TUI rendering +- Migrate existing `FeatureConfig` flags to experiments over time + +### Deprecation path +- Old `FeatureConfig` fields stay for one release with deprecation warnings +- Config migration: `features.dcp_enabled` → `experiments.dcp_enabled` +- Legacy key resolution handles the transition transparently + +--- + +## 11. Known Limitations & Future Work + +- [ ] Enum variants require recompilation to add new flags (intentional — this is a feature, not a bug, for type safety) +- [ ] No remote/telemetry-based flag toggling (GrowthBook equivalent) — could be added on top later +- [ ] No per-session flag overrides yet (all flags are process-wide) +- [ ] No profile-based flag presets (e.g., "stable profile" vs "nightly profile") +- [ ] Enterprise `FeatureRequirementsToml` pinned constraints not implemented yet (Codex pattern for later) +- [ ] Flag dependency resolution is O(n*m) — negligible for current scale but can optimize with a DAG +- [ ] TUI popup doesn't show flag descriptions inline yet (mouse hover or expand) + +--- + +## 12. Success Criteria Checklist + +- [ ] `Experiments::check()` returns correct value for enabled/disabled flags +- [ ] `EXPERIMENT_FLAGS` static array is the single source of truth for all flags +- [ ] `jcode experiment list` shows all flags with stage, default, and current state +- [ ] `jcode experiment enable ` toggles flag on, persists to config.toml +- [ ] `jcode experiment disable ` toggles flag off, persists to config.toml +- [ ] `--json` flag works for script-friendly output +- [ ] UnderDevelopment flags show startup warning when enabled +- [ ] Deprecated flags show deprecation hint when enabled +- [ ] Removed flags always evaluate to false +- [ ] Legacy key resolution works for renamed flags +- [ ] Config round-trips: saving and reloading preserves all experiment states +- [ ] Protocol request/response works over Unix socket +- [ ] TUI popup renders checkbox list with spacebar toggle +- [ ] TUI popup changes persist to server config +- [ ] No regressions in existing `FeatureConfig` behavior +- [ ] Cargo check passes with no new warnings +- [ ] All unit tests pass +- [ ] CLI integration tests pass diff --git a/Cargo.lock b/Cargo.lock index 521ed00e9..db93f24e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5092,6 +5092,7 @@ dependencies = [ "jcode-config-types", "jcode-core", "jcode-embedding", + "jcode-experiment-flags", "jcode-gateway-types", "jcode-logging", "jcode-memory-types", @@ -5227,6 +5228,7 @@ dependencies = [ "jcode-compaction-core", "jcode-config-types", "jcode-core", + "jcode-experiment-flags", "jcode-gateway-types", "jcode-logging", "jcode-memory-types", @@ -5360,6 +5362,7 @@ dependencies = [ "jcode-config-types", "jcode-core", "jcode-embedding", + "jcode-experiment-flags", "jcode-gateway-types", "jcode-logging", "jcode-memory-types", @@ -5513,6 +5516,15 @@ dependencies = [ "tract-onnx", ] +[[package]] +name = "jcode-experiment-flags" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "strum 0.26.3", +] + [[package]] name = "jcode-gateway-types" version = "0.1.0" @@ -5848,6 +5860,7 @@ dependencies = [ "jcode-app-core", "jcode-build-meta", "jcode-core", + "jcode-experiment-flags", "jcode-logging", "jcode-message-types", "jcode-productivity-core", @@ -8256,7 +8269,7 @@ dependencies = [ "itertools 0.14.0", "kasuari", "lru 0.16.4", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -8324,7 +8337,7 @@ dependencies = [ "itertools 0.14.0", "line-clipping", "ratatui-core", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -9690,13 +9703,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 600936731..5b15f52e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ autobins = false members = [ ".", "crates/jcode-agent-runtime", + "crates/jcode-experiment-flags", "crates/jcode-app-core", "crates/jcode-base", "crates/jcode-tui", @@ -77,6 +78,7 @@ members = [ [workspace.dependencies] ffs-search = { git = "https://github.com/quangdang46/fast_file_search", rev = "4cd7f27" } ffs-engine = { git = "https://github.com/quangdang46/fast_file_search", rev = "4cd7f27" } +strum = { version = "0.26", features = ["derive"] } [lib] name = "jcode" @@ -253,6 +255,7 @@ jcode-task-types = { path = "crates/jcode-task-types" } jcode-tool-core = { path = "crates/jcode-tool-core" } jcode-tool-types = { path = "crates/jcode-tool-types" } jcode-side-panel-types = { path = "crates/jcode-side-panel-types" } +jcode-experiment-flags = { path = "crates/jcode-experiment-flags" } # Archive extraction (for auto-update) flate2 = "1" diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index d3ac6a92a..aa1e91d0e 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -120,6 +120,7 @@ jcode-build-meta = { path = "../jcode-build-meta" } # Re-exported via `pub use jcode_base::*` in lib.rs. default-features=false so # this crate controls jcode-base's optional features (see [features] below). jcode-base = { path = "../jcode-base", default-features = false } +jcode-experiment-flags = { path = "../jcode-experiment-flags" } jcode-compaction-core = { path = "../jcode-compaction-core" } jcode-config-types = { path = "../jcode-config-types" } jcode-core = { path = "../jcode-core" } diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 5518ef25e..2b9776617 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -558,7 +558,12 @@ impl Agent { memory: &crate::memory::PendingMemory, ) -> (Message, bool) { let message = Self::memory_injection_message(memory); - let persist = crate::config::config().features.persist_memory_injections; + let persist = { + let config = crate::config::config(); + let experiments = + jcode_experiment_flags::Experiments::from_config(&config.experiments.entries); + experiments.check(jcode_experiment_flags::ExperimentFlag::PersistMemoryInjection) + }; if persist { self.add_message_with_display_role( Role::User, diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs index 38fc6d646..a7d91b8fe 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -421,7 +421,12 @@ pub(super) async fn handle_client( let registry = Registry::new(provider.clone(), crate::tool::shared_agent_registry()).await; let registry_ms = t0.elapsed().as_millis(); - let mut swarm_enabled = crate::config::config().features.swarm; + // Gate swarm coordination on the SwarmCoordination experiment flag. + // Falls back to the legacy features.swarm value if the experiment is not set. + let mut swarm_enabled = jcode_experiment_flags::Experiments::from_config( + &crate::config::config().experiments.entries, + ) + .check(jcode_experiment_flags::ExperimentFlag::SwarmCoordination); let mut last_available_models_snapshot: Option = None; const MAX_LIVE_AVAILABLE_MODELS_UPDATE_BYTES: usize = 64 * 1024; @@ -2409,6 +2414,40 @@ pub(super) async fn handle_client( .await; } + Request::ExperimentList { id: _ } => { + let config = crate::config::config(); + let experiments = + jcode_experiment_flags::Experiments::from_config(&config.experiments.entries); + let states = experiments.all_flag_states(); + let flags: Vec = states + .iter() + .map(|s| jcode_protocol::ExperimentFlagWire { + flag: format!("{:?}", s.flag), + key: s.key.to_string(), + stage: format!("{:?}", s.stage), + enabled: s.enabled, + default_enabled: s.default_enabled, + }) + .collect(); + let _ = client_event_tx.send(ServerEvent::ExperimentFlags { flags }); + } + + Request::ExperimentSet { id, key, enabled } => { + let mut config = crate::config::Config::load(); + config.experiments.entries.insert(key, enabled); + if let Err(e) = config.save() { + crate::logging::error(&format!("Failed to save experiment config: {e}")); + let _ = client_event_tx.send(ServerEvent::Error { + id, + message: format!("Failed to save experiment config: {e}"), + retry_after_secs: None, + }); + } else { + crate::config::invalidate_config_cache(); + let _ = client_event_tx.send(ServerEvent::Done { id }); + } + } + // These are handled via channels, not direct requests from TUI Request::ClientDebugCommand { id, .. } => { handle_client_debug_command(id, &client_event_tx).await; diff --git a/crates/jcode-app-core/src/server/headless.rs b/crates/jcode-app-core/src/server/headless.rs index 8dc03feaa..b632d8fd9 100644 --- a/crates/jcode-app-core/src/server/headless.rs +++ b/crates/jcode-app-core/src/server/headless.rs @@ -36,7 +36,10 @@ pub(super) async fn create_headless_session( report_back_to_session_id: Option, ) -> Result { let memory_enabled = crate::config::config().features.memory; - let swarm_enabled = crate::config::config().features.swarm; + let swarm_enabled = jcode_experiment_flags::Experiments::from_config( + &crate::config::config().experiments.entries, + ) + .check(jcode_experiment_flags::ExperimentFlag::SwarmCoordination); let working_dir = if let Some(path_str) = command.strip_prefix("create_session:") { let path_str = path_str.trim(); diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 45f04f39c..9a5ff0cad 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -126,6 +126,7 @@ jcode-build-support = { path = "../jcode-build-support" } jcode-build-meta = { path = "../jcode-build-meta" } jcode-compaction-core = { path = "../jcode-compaction-core" } jcode-config-types = { path = "../jcode-config-types" } +jcode-experiment-flags = { path = "../jcode-experiment-flags" } jcode-core = { path = "../jcode-core" } jcode-memory-types = { path = "../jcode-memory-types" } jcode-message-types = { path = "../jcode-message-types" } diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index a0bf1744e..f79e56846 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -6,11 +6,11 @@ pub use jcode_config_types::{ AgentsConfig, AmbientConfig, AuthConfig, AutoJudgeConfig, AutoReviewConfig, CompactionConfig, CompactionMode, CrossProviderFailoverMode, DiagramDisplayMode, DiagramPanePosition, - DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, KeybindingsConfig, - MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig, - NamedProviderType, NativeScrollbarConfig, ProviderConfig, ReasoningDisplayMode, SafetyConfig, - SessionPickerResumeAction, SwarmSpawnMode, TerminalConfig, UpdateChannel, WebSearchConfig, - WebSearchEngine, + DiffDisplayMode, DisplayConfig, ExperimentConfig, FeatureConfig, GatewayConfig, + KeybindingsConfig, MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, + NamedProviderModelConfig, NamedProviderType, NativeScrollbarConfig, ProviderConfig, + ReasoningDisplayMode, SafetyConfig, SessionPickerResumeAction, SwarmSpawnMode, TerminalConfig, + UpdateChannel, WebSearchConfig, WebSearchEngine, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -393,6 +393,10 @@ pub struct Config { /// Feature toggles pub features: FeatureConfig, + /// Experiment flags section + #[serde(default)] + pub experiments: ExperimentConfig, + /// Web search tool configuration pub websearch: WebSearchConfig, diff --git a/crates/jcode-base/src/config/config_file.rs b/crates/jcode-base/src/config/config_file.rs index 5df9b1c54..31675f118 100644 --- a/crates/jcode-base/src/config/config_file.rs +++ b/crates/jcode-base/src/config/config_file.rs @@ -58,6 +58,16 @@ impl Config { anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e) })?; config.display.apply_legacy_compat(); + // Migrate legacy [features] toggles into [experiments] for users who + // haven't yet updated their config. Non-default values are propagated + // so behavior is preserved across the FeatureConfig -> ExperimentConfig + // transition. + jcode_experiment_flags::migrate_feature_legacy_into( + &mut config.experiments.entries, + Some(config.features.dcp_enabled), + Some(config.features.swarm), + Some(config.features.persist_memory_injections), + ); Ok(Some(config)) } diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index dd3a17b6e..6cd2d67b8 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -311,6 +311,9 @@ impl Config { if let Ok(v) = std::env::var("JCODE_SWARM_ENABLED") { if let Some(parsed) = parse_env_bool(&v) { self.features.swarm = parsed; + self.experiments + .entries + .insert("swarm".to_string(), parsed); } } if let Ok(v) = std::env::var("JCODE_MESSAGE_TIMESTAMPS") { @@ -321,6 +324,9 @@ impl Config { if let Ok(v) = std::env::var("JCODE_PERSIST_MEMORY_INJECTIONS") { if let Some(parsed) = parse_env_bool(&v) { self.features.persist_memory_injections = parsed; + self.experiments + .entries + .insert("persist_memory_injections".to_string(), parsed); } } if let Ok(v) = std::env::var("JCODE_UPDATE_CHANNEL") { @@ -665,6 +671,31 @@ impl Config { } } + // Experiment flags: JCODE_EXPERIMENTS overrides config + // Format: comma-separated key=value pairs + if let Ok(raw) = std::env::var("JCODE_EXPERIMENTS") { + for pair in raw.split(',') { + let pair = pair.trim(); + if let Some((key, value)) = pair.split_once('=') { + let key = key.trim().to_string(); + if let Some(val) = parse_env_bool(value.trim()) { + self.experiments.entries.insert(key, val); + } + } + } + } + + // Individual experiment flag env vars (higher priority than JCODE_EXPERIMENTS) + // JCODE_DCP_ENABLED, JCODE_SWARM, JCODE_HOOKS_V2, etc. + for spec in jcode_experiment_flags::EXPERIMENT_FLAGS { + let env_key = format!("JCODE_{}", spec.key.to_uppercase()); + if let Ok(raw) = std::env::var(&env_key) { + if let Some(val) = parse_env_bool(&raw) { + self.experiments.entries.insert(spec.key.to_string(), val); + } + } + } + // Copilot premium mode: env var overrides config // If set in config but not in env, propagate config -> env if let Ok(v) = std::env::var("JCODE_COPILOT_PREMIUM") { diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 238092b1c..4a5e7aa86 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -775,6 +775,18 @@ impl Default for FeatureConfig { } } +/// Experiment flags section in config.toml — dynamically keyed. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ExperimentConfig { + /// Dynamic experiment flag entries, e.g.: + /// [experiments] + /// hooks_v2 = true + /// js_plugins = false + #[serde(flatten)] + pub entries: BTreeMap, +} + /// Search engine used by the websearch tool. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] #[serde(rename_all = "lowercase")] diff --git a/crates/jcode-experiment-flags/Cargo.toml b/crates/jcode-experiment-flags/Cargo.toml new file mode 100644 index 000000000..1bd12c55a --- /dev/null +++ b/crates/jcode-experiment-flags/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "jcode-experiment-flags" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +strum = { workspace = true, features = ["derive"] } diff --git a/crates/jcode-experiment-flags/src/lib.rs b/crates/jcode-experiment-flags/src/lib.rs new file mode 100644 index 000000000..23722d67a --- /dev/null +++ b/crates/jcode-experiment-flags/src/lib.rs @@ -0,0 +1,599 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +// ============================================================================ +// Stage — lifecycle stage of an experiment flag +// ============================================================================ + +/// Lifecycle stage of an experiment flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + /// Internal-only, not stable enough for users. Emits warning when enabled. + UnderDevelopment, + /// Ready for early adopters. Visible in `jcode experiment list` and TUI popup. + Experimental { + name: &'static str, + menu_description: &'static str, + /// A one-line announcement message shown when the flag becomes experimental. + announcement: Option<&'static str>, + }, + /// Stable and enabled by default. No longer shown in experiment list. + Stable, + /// Still works but scheduled for removal. Emits deprecation warning. + Deprecated { + /// Message explaining what to use instead. + migration_hint: &'static str, + }, + /// Removed. Flag still parsed for config backwards compat but always evaluates to false. + Removed, +} + +// ============================================================================ +// ExperimentFlag — unique identifier for each experiment flag +// ============================================================================ + +/// Unique identifier for each experiment flag (enum-based, type-safe). +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, strum::Display, +)] +#[strum(serialize_all = "snake_case")] +pub enum ExperimentFlag { + /// Dynamic Context Pruning + DynamicContextPruning, + /// Swarm coordination + SwarmCoordination, + /// V2 Hooks system (28 events, parallel dispatch) + HooksV2, + /// JavaScript plugin runtime (QuickJS embedded) + JsPlugins, + /// Persistent memory injection + PersistMemoryInjection, + /// Reasoning trace display in TUI + ReasoningTrace, +} + +// ============================================================================ +// FeatureSpec — static specification for an experiment flag +// ============================================================================ + +/// Static specification for an experiment flag — single source of truth. +#[derive(Debug, Clone, Copy)] +pub struct FeatureSpec { + pub id: ExperimentFlag, + /// TOML key name (e.g., "hooks_v2"). + pub key: &'static str, + /// Current lifecycle stage. + pub stage: Stage, + /// Whether the flag defaults to enabled. + pub default_enabled: bool, + /// Feature IDs that must also be enabled for this flag to work. + pub dependencies: &'static [ExperimentFlag], +} + +/// All experiment flags defined in the system — single source of truth. +pub static EXPERIMENT_FLAGS: &[FeatureSpec] = &[ + FeatureSpec { + id: ExperimentFlag::DynamicContextPruning, + key: "dcp_enabled", + stage: Stage::Stable, + default_enabled: true, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::SwarmCoordination, + key: "swarm", + stage: Stage::Stable, + default_enabled: true, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::HooksV2, + key: "hooks_v2", + stage: Stage::UnderDevelopment, + default_enabled: false, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::JsPlugins, + key: "js_plugins", + stage: Stage::UnderDevelopment, + default_enabled: false, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::PersistMemoryInjection, + key: "persist_memory_injections", + stage: Stage::Experimental { + name: "Persist Memory Injections", + menu_description: "Persist auto-recalled memory injections into normal session history", + announcement: None, + }, + default_enabled: false, + dependencies: &[], + }, + FeatureSpec { + id: ExperimentFlag::ReasoningTrace, + key: "reasoning_trace", + stage: Stage::Experimental { + name: "Reasoning Trace", + menu_description: "Show model reasoning trace in TUI output", + announcement: Some("Reasoning traces now available in TUI — /experimental to enable"), + }, + default_enabled: false, + dependencies: &[], + }, +]; + +// ============================================================================ +// FlagState — display state for one experiment flag +// ============================================================================ + +/// Display state for one experiment flag (used for TUI/CLI serialization). +#[derive(Debug, Clone, Serialize)] +pub struct FlagState { + pub flag: ExperimentFlag, + pub key: &'static str, + pub stage: Stage, + pub enabled: bool, + pub default_enabled: bool, +} + +// ============================================================================ +// LegacyUsage — tracking for deprecated/renamed flag usages +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegacyUsage { + pub key: String, + pub resolved_to: ExperimentFlag, + pub count: u64, +} + +// ============================================================================ +// Experiments — runtime representation of flag enablement state +// ============================================================================ + +/// Runtime representation of flag enablement state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Experiments { + /// The set of enabled experiment flags. + enabled: BTreeSet, + /// Tracking for deprecated/renamed flag usages. + #[serde(skip)] + #[allow(dead_code)] + legacy_usages: Vec, +} + +impl Experiments { + /// Create with default-enabled flags. + pub fn with_defaults() -> Self { + let mut enabled = BTreeSet::new(); + for spec in EXPERIMENT_FLAGS { + if spec.default_enabled { + enabled.insert(spec.id); + } + } + Self { + enabled, + legacy_usages: Vec::new(), + } + } + + /// Apply user overrides and validate. + /// + /// Note: startup warnings are NOT emitted here. Call `emit_startup_warnings()` + /// once at process startup instead — `from_config()` may be called per-client. + pub fn from_config(toml_entries: &BTreeMap) -> Self { + let mut ex = Experiments::with_defaults(); + ex.apply_map(toml_entries); + ex.normalize_dependencies(); + ex + } + + /// Check if a flag is enabled. + pub fn check(&self, flag: ExperimentFlag) -> bool { + // Removed flags always evaluate to false + if let Some(spec) = EXPERIMENT_FLAGS.iter().find(|s| s.id == flag) + && matches!(spec.stage, Stage::Removed) + { + return false; + } + self.enabled.contains(&flag) + } + + /// Enable a flag. + pub fn enable(&mut self, flag: ExperimentFlag) { + self.enabled.insert(flag); + } + + /// Disable a flag. + pub fn disable(&mut self, flag: ExperimentFlag) { + self.enabled.remove(&flag); + } + + /// Set flag state from the provided map. + pub fn apply_map(&mut self, map: &BTreeMap) { + for (key, value) in map { + if let Some(flag) = Self::resolve_key(key) { + if *value { + self.enabled.insert(flag); + } else { + self.enabled.remove(&flag); + } + } + } + } + + /// Resolve a string key to an ExperimentFlag (with legacy support). + pub fn resolve_key(key: &str) -> Option { + for spec in EXPERIMENT_FLAGS { + if spec.key == key { + return Some(spec.id); + } + } + // Legacy/renamed keys + match key { + "memory" => Some(ExperimentFlag::DynamicContextPruning), + "collab" => Some(ExperimentFlag::SwarmCoordination), + _ => None, + } + } + + /// Normalize dependencies: auto-enable required flags. + pub fn normalize_dependencies(&mut self) { + let mut changed = true; + while changed { + changed = false; + for spec in EXPERIMENT_FLAGS { + if self.enabled.contains(&spec.id) { + for dep in spec.dependencies { + if self.enabled.insert(*dep) { + changed = true; + } + } + } + } + } + } + + /// Get all flags that are currently enabled. + pub fn enabled_flags(&self) -> Vec { + self.enabled.iter().copied().collect() + } + + /// Get the list of all flags with their current state, for TUI/CLI display. + pub fn all_flag_states(&self) -> Vec { + EXPERIMENT_FLAGS + .iter() + .map(|spec| FlagState { + flag: spec.id, + key: spec.key, + stage: spec.stage, + enabled: self.enabled.contains(&spec.id), + default_enabled: spec.default_enabled, + }) + .collect() + } + + /// Emit startup warnings for enabled flags that are UnderDevelopment or Deprecated. + /// Call this once at application startup after loading config. + pub fn emit_startup_warnings(&self) { + self.warn_flag_states(); + } + + fn warn_flag_states(&self) { + for spec in EXPERIMENT_FLAGS { + if !self.enabled.contains(&spec.id) { + continue; + } + match spec.stage { + Stage::UnderDevelopment => { + eprintln!( + "[jcode] WARNING: UnderDevelopment flag '{}' is enabled. \ + This feature is not ready for production use.", + spec.key + ); + } + Stage::Deprecated { migration_hint } => { + eprintln!( + "[jcode] NOTICE: Deprecated flag '{}' is enabled. {}", + spec.key, migration_hint + ); + } + _ => {} + } + } + } +} + +// ============================================================================ +// ExperimentsToml — TOML deserialization +// ============================================================================ + +/// TOML deserialization of the [experiments] section. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ExperimentsToml { + /// Flattened key-value pairs, e.g. hooks_v2 = true + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl ExperimentsToml { + /// Materialize into Experiments struct. + pub fn materialize(&self) -> Experiments { + let mut experiments = Experiments::with_defaults(); + experiments.apply_map(&self.entries); + experiments.normalize_dependencies(); + experiments + } +} + +// ============================================================================ +// ExperimentFlagInfo — TUI display info +// ============================================================================ + +/// Flag information for TUI display (one row in the experimental features popup). +#[derive(Debug, Clone)] +pub struct ExperimentFlagInfo { + pub flag: ExperimentFlag, + pub key: String, + pub name: String, + pub description: String, + pub stage: Stage, + pub enabled: bool, +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_defaults_stable_enabled() { + let ex = Experiments::with_defaults(); + assert!(ex.check(ExperimentFlag::DynamicContextPruning)); + assert!(ex.check(ExperimentFlag::SwarmCoordination)); + assert!(!ex.check(ExperimentFlag::HooksV2)); + assert!(!ex.check(ExperimentFlag::JsPlugins)); + } + + #[test] + fn test_enable_flag() { + let mut ex = Experiments::with_defaults(); + ex.enable(ExperimentFlag::HooksV2); + assert!(ex.check(ExperimentFlag::HooksV2)); + } + + #[test] + fn test_disable_flag() { + let mut ex = Experiments::with_defaults(); + ex.disable(ExperimentFlag::DynamicContextPruning); + assert!(!ex.check(ExperimentFlag::DynamicContextPruning)); + } + + #[test] + fn test_apply_map() { + let mut ex = Experiments::with_defaults(); + let mut map = BTreeMap::new(); + map.insert("hooks_v2".to_string(), true); + map.insert("dcp_enabled".to_string(), false); + ex.apply_map(&map); + assert!(ex.check(ExperimentFlag::HooksV2)); + assert!(!ex.check(ExperimentFlag::DynamicContextPruning)); + } + + #[test] + fn test_dependency_normalization() { + let mut ex = Experiments::with_defaults(); + ex.normalize_dependencies(); + // No-op if no deps enabled — just verify it runs + } + + #[test] + fn test_legacy_key_resolution() { + assert_eq!( + Experiments::resolve_key("memory"), + Some(ExperimentFlag::DynamicContextPruning) + ); + assert_eq!( + Experiments::resolve_key("collab"), + Some(ExperimentFlag::SwarmCoordination) + ); + assert_eq!(Experiments::resolve_key("unknown_key"), None); + } + + #[test] + fn test_all_flag_states_length() { + let ex = Experiments::with_defaults(); + let states = ex.all_flag_states(); + assert_eq!(states.len(), EXPERIMENT_FLAGS.len()); + } + + #[test] + fn test_legacy_migrate_feature_into() { + use std::collections::BTreeMap; + // User explicitly disabled dcp_enabled (default true) and enabled + // persist_memory_injections (default false). Those should be migrated. + let mut exps = BTreeMap::new(); + migrate_feature_legacy_into( + &mut exps, + Some(false), // dcp_enabled explicitly off + None, // swarm at default + Some(true), // persist_memory_injections explicitly on + ); + assert_eq!(exps.get("dcp_enabled"), Some(&false)); + assert_eq!(exps.get("persist_memory_injections"), Some(&true)); + assert!(!exps.contains_key("swarm")); + } + + #[test] + fn test_legacy_migrate_no_clobber() { + use std::collections::BTreeMap; + // If the user already set the experiment explicitly, do not overwrite it. + let mut exps = BTreeMap::new(); + exps.insert("dcp_enabled".to_string(), true); + migrate_feature_legacy_into( + &mut exps, + Some(false), // would normally migrate, but already set + None, + None, + ); + assert_eq!(exps.get("dcp_enabled"), Some(&true)); + } + + #[test] + fn test_legacy_migrate_default_noop() { + use std::collections::BTreeMap; + // Default values should NOT be migrated (no surprise behavior change). + let mut exps = BTreeMap::new(); + migrate_feature_legacy_into( + &mut exps, + Some(true), // dcp_enabled at default (true) + Some(true), // swarm at default (true) + Some(false), // persist_memory_injections at default (false) + ); + assert!(exps.is_empty()); + } + + #[test] + fn test_removed_flag_always_false() { + // Removed flags should always evaluate to false regardless of enabled state. + // We can verify this by checking the spec for any flag in Removed stage. + // Since we have no Removed flags in our registry currently, we test that + // Removed stage is properly defined and would behave correctly. + let removed_stage = Stage::Removed; + assert!(matches!(removed_stage, Stage::Removed)); + } + + #[test] + fn test_serialization_roundtrip() { + let mut ex = Experiments::with_defaults(); + ex.enable(ExperimentFlag::HooksV2); + let json = serde_json::to_string(&ex).unwrap(); + let deserialized: Experiments = serde_json::from_str(&json).unwrap(); + assert!(deserialized.check(ExperimentFlag::HooksV2)); + } + + #[test] + fn test_from_config() { + let mut map = BTreeMap::new(); + map.insert("hooks_v2".to_string(), true); + map.insert("dcp_enabled".to_string(), false); + let ex = Experiments::from_config(&map); + assert!(ex.check(ExperimentFlag::HooksV2)); + assert!(!ex.check(ExperimentFlag::DynamicContextPruning)); + } + + #[test] + fn test_toml_deserialization() { + let mut config = ExperimentsToml::default(); + config.entries.insert("hooks_v2".to_string(), true); + config.entries.insert("js_plugins".to_string(), false); + assert_eq!(config.entries.get("hooks_v2"), Some(&true)); + assert_eq!(config.entries.get("js_plugins"), Some(&false)); + } + + #[test] + fn test_experiment_flag_display() { + assert_eq!( + ExperimentFlag::DynamicContextPruning.to_string(), + "dynamic_context_pruning" + ); + assert_eq!(ExperimentFlag::HooksV2.to_string(), "hooks_v2"); + } +} + +// ============================================================================ +// Migration from FeatureConfig to ExperimentConfig +// ============================================================================ + +/// Migration map from `FeatureConfig` legacy fields to `ExperimentConfig` keys. +/// +/// Returns the list of (experiment_key, value) pairs to inject into the +/// `[experiments]` section when the corresponding `FeatureConfig` field is +/// explicitly set to a non-default value (indicating user intent). +/// +/// Legacy fields kept in `FeatureConfig` for one release: +/// - `features.dcp_enabled` → `experiments.dcp_enabled` (flag: DynamicContextPruning) +/// - `features.swarm` → `experiments.swarm` (flag: SwarmCoordination) +/// - `features.persist_memory_injections` → `experiments.persist_memory_injections` +/// (flag: PersistMemoryInjection) +pub fn legacy_feature_to_experiment_migrations() -> &'static [(&'static str, &'static str)] { + &[ + ("dcp_enabled", "dcp_enabled"), + ("swarm", "swarm"), + ("persist_memory_injections", "persist_memory_injections"), + ] +} + +/// Apply legacy `FeatureConfig` → `ExperimentConfig` migration. +/// +/// Injects the corresponding entry into `experiments.entries` for each known +/// legacy `FeatureConfig` key whose experiment value is not already set +/// explicitly. This is called once at config load so the new section +/// transparently picks up the user's existing toggles. +/// +/// `legacy_overrides` is a map of legacy `FeatureConfig` key → value as +/// observed in the user's config (only non-default values should be passed). +pub fn migrate_legacy_to_experiments( + experiments: &mut std::collections::BTreeMap, + legacy_overrides: &std::collections::BTreeMap, +) { + for (legacy_key, exp_key) in legacy_feature_to_experiment_migrations() { + // Don't clobber an existing explicit experiment setting. + if experiments.contains_key(*exp_key) { + continue; + } + if let Some(&value) = legacy_overrides.get(*legacy_key) { + experiments.insert(exp_key.to_string(), value); + } + } +} + +/// Apply legacy migration using full `FeatureConfig` defaults as a baseline. +/// +/// Reads only the legacy keys (`dcp_enabled`, `swarm`, +/// `persist_memory_injections`) — if the user set them to a non-default value, +/// propagates the override into `experiments`. Keys matching the `FeatureConfig` +/// default are NOT migrated, so users who never touched the legacy fields see +/// no surprise behavior change. +/// +/// This variant avoids requiring `jcode-config-types` as a dependency, so the +/// migration can be driven by the jcode-base config layer with raw TOML values. +pub fn migrate_feature_legacy_into( + experiments: &mut std::collections::BTreeMap, + dcp_enabled: Option, + swarm: Option, + persist_memory_injections: Option, +) { + // Default values for FeatureConfig + const DEFAULT_DCP_ENABLED: bool = true; + const DEFAULT_SWARM: bool = true; + const DEFAULT_PERSIST_MEMORY: bool = false; + + let pairs: [(&str, Option, bool); 3] = [ + ("dcp_enabled", dcp_enabled, DEFAULT_DCP_ENABLED), + ("swarm", swarm, DEFAULT_SWARM), + ( + "persist_memory_injections", + persist_memory_injections, + DEFAULT_PERSIST_MEMORY, + ), + ]; + + for (key, value, default) in pairs { + if experiments.contains_key(key) { + continue; + } + if let Some(v) = value + && v != default + { + experiments.insert(key.to_string(), v); + } + } +} diff --git a/crates/jcode-protocol/src/lib.rs b/crates/jcode-protocol/src/lib.rs index c74781f47..e10c42756 100644 --- a/crates/jcode-protocol/src/lib.rs +++ b/crates/jcode-protocol/src/lib.rs @@ -171,7 +171,7 @@ impl AuthChanged { pub type ReloadRecoverySnapshot = jcode_selfdev_types::ReloadRecoveryDirective; mod wire; -pub use wire::{Request, ServerEvent}; +pub use wire::{ExperimentFlagWire, Request, ServerEvent}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolCallSummary { @@ -441,6 +441,8 @@ impl Request { Request::CommSubscribeChannel { id, .. } => *id, Request::CommUnsubscribeChannel { id, .. } => *id, Request::CommAwaitMembers { id, .. } => *id, + Request::ExperimentList { id } => *id, + Request::ExperimentSet { id, .. } => *id, } } diff --git a/crates/jcode-protocol/src/wire.rs b/crates/jcode-protocol/src/wire.rs index d2f1d0edf..1536ac574 100644 --- a/crates/jcode-protocol/src/wire.rs +++ b/crates/jcode-protocol/src/wire.rs @@ -573,6 +573,14 @@ pub enum Request { #[serde(default)] timeout_secs: Option, }, + + /// Request the current experiment flag states from the server + #[serde(rename = "experiment_list")] + ExperimentList { id: u64 }, + + /// Enable or disable an experiment flag on the server + #[serde(rename = "experiment_set")] + ExperimentSet { id: u64, key: String, enabled: bool }, } /// Server event sent to client @@ -1239,4 +1247,18 @@ pub enum ServerEvent { /// Tool call ID this is associated with tool_call_id: String, }, + + /// Current experiment flag states (response to ExperimentList) + #[serde(rename = "experiment_flags")] + ExperimentFlags { flags: Vec }, +} + +/// Typed wire representation of a single experiment flag state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExperimentFlagWire { + pub flag: String, + pub key: String, + pub stage: String, + pub enabled: bool, + pub default_enabled: bool, } diff --git a/crates/jcode-tui/Cargo.toml b/crates/jcode-tui/Cargo.toml index 4156e0e5a..10f1724b3 100644 --- a/crates/jcode-tui/Cargo.toml +++ b/crates/jcode-tui/Cargo.toml @@ -91,6 +91,7 @@ jcode-tui-usage-overlay = { path = "../jcode-tui-usage-overlay" } jcode-terminal-image = { path = "../jcode-terminal-image" } jcode-productivity-core = { path = "../jcode-productivity-core" } jcode-tui-workspace = { path = "../jcode-tui-workspace" } +jcode-experiment-flags = { path = "../jcode-experiment-flags" } [features] default = ["pdf", "embeddings"] diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 556cf01b0..5c787c0c7 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -1143,6 +1143,8 @@ pub struct App { account_picker_overlay: Option>, /// Usage overlay (None = not visible) usage_overlay: Option>, + /// Experiment flags popup overlay (None = not visible) + experiment_popup: Option>, /// Whether a usage refresh request is currently in flight. usage_report_refreshing: bool, /// Whether a `/productivity` report generation is currently in flight. diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 6407e6412..112ad448a 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -3659,3 +3659,38 @@ pub(super) fn handle_dcp_command(app: &mut App, trimmed: &str) -> bool { #[cfg(test)] #[path = "commands_tests.rs"] mod tests; + +/// Handle /experimental command: open the experiment flags popup. +pub(super) fn handle_experimental_command(app: &mut App, trimmed: &str) -> bool { + if trimmed != "/experimental" && trimmed != "/experiments" { + return false; + } + + let popup = crate::tui::experiment_popup::ExperimentPopupState::from_config(); + if popup.is_empty() { + app.push_display_message(DisplayMessage::system( + "No experimental features available at this time.".to_string(), + )); + return true; + } + app.experiment_popup = Some(std::cell::RefCell::new(popup)); + true +} + +/// Enable an experiment flag in the local config (used by TUI popup apply). +pub(super) fn handle_experiment_enable_local(_app: &mut App, key: &str) -> anyhow::Result<()> { + let mut config = crate::config::Config::load(); + config.experiments.entries.insert(key.to_string(), true); + config.save()?; + crate::config::invalidate_config_cache(); + Ok(()) +} + +/// Disable an experiment flag in the local config (used by TUI popup apply). +pub(super) fn handle_experiment_disable_local(_app: &mut App, key: &str) -> anyhow::Result<()> { + let mut config = crate::config::Config::load(); + config.experiments.entries.insert(key.to_string(), false); + config.save()?; + crate::config::invalidate_config_cache(); + Ok(()) +} diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 16aec01ef..3ebec0904 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2558,6 +2558,41 @@ pub(super) fn handle_modal_key( return Ok(true); } + if let Some(ref popup_cell) = app.experiment_popup { + use crate::tui::experiment_popup::ExperimentPopupAction; + let action = { + let mut popup = popup_cell.borrow_mut(); + popup.handle_key(code) + }; + match action { + ExperimentPopupAction::Cancel => { + app.experiment_popup = None; + } + ExperimentPopupAction::Apply { changes } => { + let mut applied = 0usize; + for (key, enabled) in &changes { + let result = if *enabled { + super::commands::handle_experiment_enable_local(app, key) + } else { + super::commands::handle_experiment_disable_local(app, key) + }; + if result.is_ok() { + applied += 1; + } + } + if !changes.is_empty() { + app.push_display_message(jcode_tui_messages::DisplayMessage::system(format!( + "Applied {} experiment flag change(s).", + applied + ))); + } + app.experiment_popup = None; + } + ExperimentPopupAction::Continue => {} + } + return Ok(true); + } + if app.copy_selection_mode { if modifiers.contains(KeyModifiers::CONTROL) && matches!(code, KeyCode::Char('c') | KeyCode::Char('d')) @@ -3452,6 +3487,7 @@ impl App { || super::commands::handle_feedback_command(self, trimmed) || super::state_ui::handle_info_command(self, trimmed) || super::auth::handle_auth_command(self, trimmed) + || super::commands::handle_experimental_command(self, trimmed) || super::tui_lifecycle_runtime::handle_dev_command(self, trimmed); if handled { if trimmed.starts_with('/') { diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 6ecd025d5..748ec5fd3 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -111,6 +111,8 @@ pub(super) const REGISTERED_COMMANDS: &[RegisteredCommand] = &[ ), RegisteredCommand::public("/version", "Show current version"), RegisteredCommand::public("/changelog", "Show recent changes in this build"), + RegisteredCommand::public("/experimental", "Toggle experiment flags with a popup"), + RegisteredCommand::hidden("/experiments", "Alias for /experimental"), RegisteredCommand::public("/info", "Show session info and tokens"), RegisteredCommand::public("/usage", "Show connected provider usage limits"), RegisteredCommand::public( diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 0e6ef52f8..be1ff75cb 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -590,6 +590,7 @@ impl App { login_picker_overlay: None, account_picker_overlay: None, usage_overlay: None, + experiment_popup: None, usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, @@ -995,6 +996,7 @@ impl App { login_picker_overlay: None, account_picker_overlay: None, usage_overlay: None, + experiment_popup: None, usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 1c7c72ced..1d6e20fb8 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1475,6 +1475,12 @@ impl crate::tui::TuiState for App { self.usage_overlay.as_ref() } + fn experiment_popup( + &self, + ) -> Option<&RefCell> { + self.experiment_popup.as_ref() + } + fn working_dir(&self) -> Option { self.session.working_dir.clone() } diff --git a/crates/jcode-tui/src/tui/experiment_popup.rs b/crates/jcode-tui/src/tui/experiment_popup.rs new file mode 100644 index 000000000..58cae304d --- /dev/null +++ b/crates/jcode-tui/src/tui/experiment_popup.rs @@ -0,0 +1,168 @@ +use crossterm::event::KeyCode; +use jcode_experiment_flags::{EXPERIMENT_FLAGS, ExperimentFlag, Experiments, Stage}; + +/// State for the experiment flags popup overlay. +#[derive(Debug, Clone)] +pub struct ExperimentPopupState { + /// Current toggle states (key → enabled). + flags: Vec, + /// Cursor position (index into `flags`). + selected: usize, + /// Scroll offset for the list. + scroll: usize, +} + +#[derive(Debug, Clone)] +pub struct ExperimentPopupEntry { + key: &'static str, + #[allow(dead_code)] + flag: ExperimentFlag, + stage: Stage, + name: String, + description: String, + enabled: bool, + default_enabled: bool, +} + +/// Action returned after handling a key event in the popup. +pub enum ExperimentPopupAction { + Continue, + Cancel, + Apply { changes: Vec<(String, bool)> }, +} + +impl ExperimentPopupState { + /// Build popup state from the current config. + pub fn from_config() -> Self { + let config = crate::config::config(); + let experiments = Experiments::from_config(&config.experiments.entries); + let mut flags = Vec::new(); + + for spec in EXPERIMENT_FLAGS { + // Only show Experimental and UnderDevelopment stage flags + let (name, description) = match &spec.stage { + Stage::Experimental { + name, + menu_description, + .. + } => (name.to_string(), menu_description.to_string()), + Stage::UnderDevelopment => ( + format!("{:?}", spec.id), + "Under development — not ready for general use".to_string(), + ), + _ => continue, + }; + + flags.push(ExperimentPopupEntry { + key: spec.key, + flag: spec.id, + stage: spec.stage, + name, + description, + enabled: experiments.check(spec.id), + default_enabled: spec.default_enabled, + }); + } + + Self { + flags, + selected: 0, + scroll: 0, + } + } + + /// Number of visible flags. + #[allow(dead_code)] + pub fn len(&self) -> usize { + self.flags.len() + } + + /// Whether the popup is empty (no experimental flags visible). + pub fn is_empty(&self) -> bool { + self.flags.is_empty() + } + + /// Handle a key press. Returns the action to take. + pub fn handle_key(&mut self, code: KeyCode) -> ExperimentPopupAction { + match code { + KeyCode::Esc | KeyCode::Char('q') => ExperimentPopupAction::Cancel, + KeyCode::Up | KeyCode::Char('k') => { + if self.selected > 0 { + self.selected -= 1; + } + ExperimentPopupAction::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + if self.selected + 1 < self.flags.len() { + self.selected += 1; + } + ExperimentPopupAction::Continue + } + KeyCode::Char(' ') => { + if let Some(entry) = self.flags.get_mut(self.selected) { + entry.enabled = !entry.enabled; + } + ExperimentPopupAction::Continue + } + KeyCode::Enter => { + let changes: Vec<(String, bool)> = self + .flags + .iter() + .filter(|e| e.enabled != e.default_enabled) + .map(|e| (e.key.to_string(), e.enabled)) + .collect(); + ExperimentPopupAction::Apply { changes } + } + _ => ExperimentPopupAction::Continue, + } + } + + /// Get the current cursor index. + pub fn selected(&self) -> usize { + self.selected + } + + /// Get the current scroll offset. + #[allow(dead_code)] + pub fn scroll(&self) -> usize { + self.scroll + } + + /// Update scroll to keep the selected item visible. + #[allow(dead_code)] + pub fn adjust_scroll(&mut self, visible_height: usize) { + if self.selected >= self.scroll + visible_height { + self.scroll = self.selected - visible_height + 1; + } else if self.selected < self.scroll { + self.scroll = self.selected; + } + } + + /// Get the entries for rendering. + pub fn entries(&self) -> &[ExperimentPopupEntry] { + &self.flags + } +} + +impl ExperimentPopupEntry { + #[allow(dead_code)] + pub fn key(&self) -> &'static str { + self.key + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn stage(&self) -> Stage { + self.stage + } +} diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index ba433e0ef..062ecbd3c 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -17,6 +17,7 @@ mod core; // so existing `crate::tui::image` / `crate::tui::image_metadata` paths keep working. pub use jcode_terminal_image::display as image; use jcode_terminal_image::metadata as image_metadata; +pub mod experiment_popup; pub mod info_widget; mod info_widget_layout; mod info_widget_overview; @@ -346,6 +347,12 @@ pub trait TuiState { fn account_picker_overlay(&self) -> Option<&std::cell::RefCell>; /// Usage overlay for /usage command fn usage_overlay(&self) -> Option<&std::cell::RefCell>; + /// Experiment flags popup for /experimental command + fn experiment_popup( + &self, + ) -> Option<&std::cell::RefCell> { + None + } /// Working directory for this session fn working_dir(&self) -> Option; /// Monotonic clock for viewport animations diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index 31ffd100c..30b2fe5f9 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -1915,6 +1915,18 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { return; } + if app.experiment_popup().is_some() { + overlays::draw_experiment_popup(frame, area, app); + finalize_frame_metrics( + app, + total_start, + Duration::ZERO, + total_start.elapsed(), + None, + ); + return; + } + // Initialize visual debug capture if enabled let mut debug_capture = if visual_debug::is_enabled() { Some(FrameCaptureBuilder::new(area.width, area.height)) diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index 1c5ed38df..5d940b839 100644 --- a/crates/jcode-tui/src/tui/ui_overlays.rs +++ b/crates/jcode-tui/src/tui/ui_overlays.rs @@ -622,3 +622,96 @@ fn color_to_rgb(color: Color) -> Option<[u8; 3]> { _ => None, } } + +pub(super) fn draw_experiment_popup(frame: &mut Frame, area: Rect, app: &dyn TuiState) { + use jcode_experiment_flags::Stage; + + clear_area(frame, area); + + let popup = match app.experiment_popup() { + Some(p) => p, + None => return, + }; + let popup = popup.borrow(); + let entries = popup.entries(); + let selected = popup.selected(); + + let check_style = Style::default().fg(rgb(120, 220, 150)); + let uncheck_style = Style::default().fg(dim_color()); + let selected_style = Style::default().bg(rgb(38, 42, 56)); + let title_style = Style::default() + .fg(accent_color()) + .add_modifier(Modifier::BOLD); + let name_style = Style::default().fg(rgb(230, 230, 240)); + let desc_style = Style::default().fg(rgb(150, 150, 165)); + let stage_style = Style::default().fg(rgb(200, 180, 120)); + let dim_style = Style::default().fg(dim_color()); + + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(Span::styled( + " Experimental Features", + title_style, + ))); + lines.push(Line::from(Span::styled( + " Toggle features on/off. Changes are saved to config.", + dim_style, + ))); + lines.push(Line::from("")); + + for (i, entry) in entries.iter().enumerate() { + let checkbox = if entry.enabled() { "☑" } else { "☐" }; + let cb_style = if entry.enabled() { + check_style + } else { + uncheck_style + }; + let stage_label = match entry.stage() { + Stage::Experimental { .. } => "experimental", + Stage::UnderDevelopment => "dev", + _ => "", + }; + + let is_selected = i == selected; + let bg = if is_selected { + selected_style + } else { + Style::default() + }; + + let mut spans = vec![ + Span::styled(if is_selected { " > " } else { " " }, dim_style.patch(bg)), + Span::styled(format!("{checkbox} "), cb_style.patch(bg)), + Span::styled(entry.name().to_string(), name_style.patch(bg)), + ]; + if !stage_label.is_empty() { + spans.push(Span::styled( + format!(" [{stage_label}]"), + stage_style.patch(bg), + )); + } + spans.push(Span::styled( + format!(" — {}", entry.description()), + desc_style.patch(bg), + )); + lines.push(Line::from(spans)); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Space toggle · Enter apply · Esc cancel · j/k scroll", + dim_style, + ))); + + let block = Block::default() + .title(Span::styled( + " /experimental ", + Style::default() + .fg(accent_color()) + .add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(dim_color())); + + let paragraph = Paragraph::new(lines).block(block).scroll((0, 0)); + frame.render_widget(paragraph, area); +} diff --git a/src/cli/args.rs b/src/cli/args.rs index fced950e5..beeb37770 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -496,6 +496,10 @@ pub(crate) enum Command { to: Option, }, + /// Manage experiment flags (list, enable, disable) + #[command(subcommand)] + Experiment(ExperimentCommand), + /// Ambient mode management #[command(subcommand)] Ambient(AmbientCommand), @@ -1054,6 +1058,28 @@ pub(crate) enum PromptsCommand { }, } +#[derive(Subcommand, Debug)] +pub(crate) enum ExperimentCommand { + /// List all experiment flags and their current state + List { + /// Emit JSON instead of human-readable output. + #[arg(long)] + json: bool, + }, + + /// Enable an experiment flag by key name + Enable { + /// Experiment flag key (e.g., "hooks_v2", "js_plugins") + key: String, + }, + + /// Disable an experiment flag by key name + Disable { + /// Experiment flag key (e.g., "hooks_v2", "js_plugins") + key: String, + }, +} + #[derive(Subcommand, Debug)] pub(crate) enum SkillsCommand { /// List all discovered skills (built-in + project + repo + user dirs). diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index 607c64578..0409f5e82 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -7,8 +7,9 @@ use std::time::Instant; use super::args::{ AmbientCommand, Args, AuthCommand, CloudCommand, CloudSessionsCommand, Command, - ExportFormatArg, McpCommand, MemoryCommand, ModelCommand, PromptsCommand, ProviderCommand, - RestartCommand, ServerCommand, SessionCommand, SkillsCommand, TranscriptModeArg, + ExperimentCommand, ExportFormatArg, McpCommand, MemoryCommand, ModelCommand, PromptsCommand, + ProviderCommand, RestartCommand, ServerCommand, SessionCommand, SkillsCommand, + TranscriptModeArg, }; use crate::{ agent, auth, build, provider, provider_catalog, server, session, setup_hints, startup_profile, @@ -16,7 +17,8 @@ use crate::{ }; use super::{ - acp, commands, debug, hot_exec, login, output, provider_init, selfdev, terminal, tui_launch, + acp, commands, debug, experiment_flags, hot_exec, login, output, provider_init, selfdev, + terminal, tui_launch, }; use provider_init::ProviderChoice; @@ -279,6 +281,17 @@ pub(crate) async fn run_main(mut args: Args) -> Result<()> { SkillsCommand::Disable { name } => commands::run_skills_disable(&name)?, SkillsCommand::Enable { name } => commands::run_skills_enable(&name)?, }, + Some(Command::Experiment(subcmd)) => match subcmd { + ExperimentCommand::List { json } => { + experiment_flags::run_experiment_list_command(json)? + } + ExperimentCommand::Enable { key } => { + experiment_flags::run_experiment_enable_command(&key)? + } + ExperimentCommand::Disable { key } => { + experiment_flags::run_experiment_disable_command(&key)? + } + }, Some(Command::Mcp(subcmd)) => match subcmd { McpCommand::Trust { path } => commands::run_mcp_trust_command(&path)?, McpCommand::Revoke { path } => commands::run_mcp_revoke_command(&path)?, diff --git a/src/cli/experiment_flags.rs b/src/cli/experiment_flags.rs new file mode 100644 index 000000000..838e03d5f --- /dev/null +++ b/src/cli/experiment_flags.rs @@ -0,0 +1,130 @@ +use anyhow::{Context, Result}; +use jcode_experiment_flags::{EXPERIMENT_FLAGS, Experiments, Stage}; + +pub fn run_experiment_list_command(json: bool) -> Result<()> { + let config = crate::config::config(); + let experiments = Experiments::from_config(&config.experiments.entries); + + if json { + let states = experiments.all_flag_states(); + println!("{}", serde_json::to_string_pretty(&states)?); + } else { + let header = format!( + "{:25} {:25} {:8} {:8} {}", + "Key", "Flag", "Default", "Current", "Stage" + ); + println!("{}", header); + println!("{}", "-".repeat(90)); + for spec in EXPERIMENT_FLAGS { + let enabled = experiments.check(spec.id); + let default_str = if spec.default_enabled { "on" } else { "off" }; + let current_str = if enabled { "ON" } else { "OFF" }; + let stage_label = match spec.stage { + Stage::UnderDevelopment => "UnderDevelopment", + Stage::Experimental { .. } => "Experimental", + Stage::Stable => "Stable", + Stage::Deprecated { .. } => "Deprecated", + Stage::Removed => "Removed", + }; + println!( + "{:25} {:25} {:8} {:8} {}", + spec.key, + format!("{:?}", spec.id), + default_str, + current_str, + stage_label, + ); + } + } + Ok(()) +} + +pub fn run_experiment_enable_command(key: &str) -> Result<()> { + if Experiments::resolve_key(key).is_none() { + anyhow::bail!( + "Unknown experiment flag '{key}'. Use 'jcode experiment list' to see valid flags." + ); + } + let mut config = crate::config::Config::load(); + config.experiments.entries.insert(key.to_string(), true); + config.save().context("Failed to save config")?; + crate::config::invalidate_config_cache(); + eprintln!("[jcode] Experiment '{key}' enabled."); + Ok(()) +} + +pub fn run_experiment_disable_command(key: &str) -> Result<()> { + if Experiments::resolve_key(key).is_none() { + anyhow::bail!( + "Unknown experiment flag '{key}'. Use 'jcode experiment list' to see valid flags." + ); + } + let mut config = crate::config::Config::load(); + config.experiments.entries.insert(key.to_string(), false); + config.save().context("Failed to save config")?; + crate::config::invalidate_config_cache(); + eprintln!("[jcode] Experiment '{key}' disabled."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use jcode_experiment_flags::Experiments; + + #[test] + fn test_run_experiment_list_json_roundtrip() { + // Build expected JSON output. + let config = crate::config::Config::default(); + let experiments = Experiments::from_config(&config.experiments.entries); + let states = experiments.all_flag_states(); + let json_str = serde_json::to_string_pretty(&states).unwrap(); + let parsed: Vec = serde_json::from_str(&json_str).unwrap(); + // We should have exactly EXPERIMENT_FLAGS.len() entries. + assert_eq!(parsed.len(), jcode_experiment_flags::EXPERIMENT_FLAGS.len()); + // Each entry should have "flag", "key", "enabled", "default_enabled" fields. + for (i, entry) in parsed.iter().enumerate() { + assert!(entry.get("flag").is_some(), "missing 'flag' at index {i}"); + assert!(entry.get("key").is_some(), "missing 'key' at index {i}"); + assert!( + entry.get("enabled").is_some(), + "missing 'enabled' at index {i}" + ); + } + } + + #[test] + fn test_run_experiment_enable_disable_roundtrip() { + // Use a temp JCODE_HOME to isolate from user config. + let tmp = tempfile::tempdir().unwrap(); + // JCODE_HOME points directly to the jcode data directory. + // SAFETY: test-only env mutation, single-threaded test harness. + unsafe { + std::env::set_var("JCODE_HOME", tmp.path().to_str().unwrap()); + } + // Initially hooks_v2 should be disabled by default. + let config = crate::config::Config::load(); + assert!( + !config + .experiments + .entries + .get("hooks_v2") + .copied() + .unwrap_or(false) + ); + // Enable and verify. + run_experiment_enable_command("hooks_v2").unwrap(); + crate::config::invalidate_config_cache(); + let config2 = crate::config::Config::load(); + assert_eq!(config2.experiments.entries.get("hooks_v2"), Some(&true)); + // Disable and verify. + run_experiment_disable_command("hooks_v2").unwrap(); + crate::config::invalidate_config_cache(); + let config3 = crate::config::Config::load(); + assert_eq!(config3.experiments.entries.get("hooks_v2"), Some(&false)); + // SAFETY: test-only env mutation, single-threaded test harness. + unsafe { + std::env::remove_var("JCODE_HOME"); + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3c39bd6f4..6eb9230c9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,6 +4,7 @@ pub mod auth_test; pub mod commands; pub mod debug; pub mod dispatch; +pub mod experiment_flags; pub mod hot_exec; pub mod login; pub mod output; diff --git a/src/cli/proctitle.rs b/src/cli/proctitle.rs index e441e63d1..8153f5454 100644 --- a/src/cli/proctitle.rs +++ b/src/cli/proctitle.rs @@ -56,6 +56,7 @@ pub(crate) fn initial_title(args: &Args) -> String { Some(Command::Logout { .. }) => "jcode logout".to_string(), Some(Command::Prompts(_)) => "jcode prompts".to_string(), Some(Command::Skills(_)) => "jcode skills".to_string(), + Some(Command::Experiment(_)) => "jcode experiment".to_string(), Some(Command::Mcp(_)) => "jcode mcp".to_string(), Some(Command::Doctor { .. }) => "jcode doctor".to_string(), Some(Command::Export { .. }) => "jcode export".to_string(),