diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index f2eb3d4b7..f3cc389b0 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -699,6 +699,12 @@ pub struct ProviderConfig { /// model that ships a different default persona, or for project-pinned /// behavior baselines. pub system_prompt: Option, + + /// User-defined ordered list of model id patterns to scope `Ctrl+P` / + /// `/scoped-models` cycling to. Patterns match by case-insensitive + /// substring or by glob (`*` and `?`). Empty = full provider list. See + /// issue #26. + pub scoped_models: Vec, } impl Default for ProviderConfig { @@ -715,6 +721,7 @@ impl Default for ProviderConfig { same_provider_account_failover: true, copilot_premium: None, system_prompt: None, + scoped_models: Vec::new(), } } } diff --git a/src/cli/args.rs b/src/cli/args.rs index 35e069988..b4c50cc5d 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -65,6 +65,13 @@ pub(crate) struct Args { #[arg(long, global = true)] pub(crate) append_system_prompt: Option, + /// Comma-separated list of model id patterns to scope `Ctrl+P` / `/scoped-models` + /// cycling to. Patterns match by case-insensitive substring or by glob (`*` and + /// `?`). Falls back to `provider.scoped_models` config when not given. + /// Equivalent to setting `JCODE_SCOPED_MODELS`. + #[arg(long = "models", global = true, value_delimiter = ',')] + pub(crate) scoped_models: Vec, + /// Log tool inputs/outputs and token usage to stderr #[arg(long, global = true)] pub(crate) trace: bool, diff --git a/src/cli/startup.rs b/src/cli/startup.rs index 686758916..44f80d221 100644 --- a/src/cli/startup.rs +++ b/src/cli/startup.rs @@ -82,6 +82,21 @@ fn parse_and_prepare_args() -> Result { crate::env::set_var("JCODE_APPEND_SYSTEM_PROMPT", text); } + // --models : translate to JCODE_SCOPED_MODELS env so cycle_model + // and the `/scoped-models` slash command can see it. Issue #26. + if !args.scoped_models.is_empty() { + let joined = args + .scoped_models + .iter() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect::>() + .join(","); + if !joined.is_empty() { + crate::env::set_var("JCODE_SCOPED_MODELS", &joined); + } + } + if let Some(ref socket) = args.socket { server::set_socket_path(socket); } diff --git a/src/lib.rs b/src/lib.rs index 2e1813725..da9781615 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ pub mod replay; pub mod restart_snapshot; pub mod runtime_memory_log; pub mod safety; +pub mod scoped_models; pub mod server; pub mod session; pub mod setup_hints; diff --git a/src/scoped_models.rs b/src/scoped_models.rs new file mode 100644 index 000000000..47e9a47b3 --- /dev/null +++ b/src/scoped_models.rs @@ -0,0 +1,183 @@ +//! Scoped-model allowlist for `Ctrl+P` / `/scoped-models` cycling (issue #26). +//! +//! A "scoped model" set is a user-defined ordered list of model id patterns +//! resolved at session start. When non-empty, model cycling is restricted to +//! entries from `available_models_for_switching()` whose ids match at least one +//! pattern. Order in the allowlist is preserved across cycles so users can +//! flip between, e.g., `sonnet:high` and `gpt-5-codex` with two key presses. +//! +//! Resolution order (highest priority first): +//! +//! 1. `JCODE_SCOPED_MODELS` env var (set by `--models` CLI flag in +//! `cli::startup::parse_and_prepare_args`). +//! 2. `provider.scoped_models` config value (`~/.jcode/config.toml`). +//! 3. Empty — cycling falls back to the full +//! `available_models_for_switching()` list (existing behavior). +//! +//! Patterns support either case-insensitive substring matching or shell-style +//! globs with `*` and `?`. The first non-empty match anywhere in the model id +//! counts. + +/// Resolve the active allowlist, in priority order. +pub fn resolve_allowlist() -> Vec { + if let Ok(value) = std::env::var("JCODE_SCOPED_MODELS") { + let parsed = parse_pattern_list(&value); + if !parsed.is_empty() { + return parsed; + } + } + + // Config-value fallback. `provider.scoped_models` is `Vec` once + // PR #200 (this PR's config schema patch) lands; before that, this branch + // is a no-op. + let cfg = crate::config::config(); + if !cfg.provider.scoped_models.is_empty() { + return cfg + .provider + .scoped_models + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + + Vec::new() +} + +fn parse_pattern_list(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Filter `available` against `patterns`, preserving the **patterns** order so +/// `cycle_model` advances along the user's intent rather than the provider's +/// default order. If `patterns` is empty, returns `available` unchanged so +/// pre-#26 behavior is bit-for-bit preserved. +pub fn filter_by_allowlist(available: &[String], patterns: &[String]) -> Vec { + if patterns.is_empty() { + return available.to_vec(); + } + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for pat in patterns { + for model in available { + if seen.contains(model) { + continue; + } + if matches_pattern(pat, model) { + seen.insert(model.clone()); + out.push(model.clone()); + } + } + } + out +} + +/// Match `pattern` against `model`. Lower-cased on both sides. +/// +/// - `*` matches any (possibly empty) span of characters. +/// - `?` matches a single character. +/// - Anything else is matched as a case-insensitive substring (so a bare +/// `sonnet` matches `claude-sonnet-4-6@1m` etc.). +fn matches_pattern(pattern: &str, model: &str) -> bool { + let pat = pattern.to_lowercase(); + let m = model.to_lowercase(); + if pat.contains('*') || pat.contains('?') { + glob_match(&pat, &m) + } else { + m.contains(&pat) + } +} + +/// Tiny glob matcher (`*` = many, `?` = single) — sufficient for model ids. +/// Avoids pulling a glob crate just for this. Iterative DP over `pat` vs `s`. +fn glob_match(pat: &str, s: &str) -> bool { + let pb = pat.as_bytes(); + let sb = s.as_bytes(); + // dp[i][j] = pat[..i] matches s[..j] + let mut dp = vec![vec![false; sb.len() + 1]; pb.len() + 1]; + dp[0][0] = true; + for i in 1..=pb.len() { + if pb[i - 1] == b'*' { + dp[i][0] = dp[i - 1][0]; + } + } + for i in 1..=pb.len() { + for j in 1..=sb.len() { + dp[i][j] = match pb[i - 1] { + b'*' => dp[i - 1][j] || dp[i][j - 1], + b'?' => dp[i - 1][j - 1], + c => dp[i - 1][j - 1] && c.eq_ignore_ascii_case(&sb[j - 1]), + }; + } + } + dp[pb.len()][sb.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn empty_allowlist_returns_input_unchanged() { + let got = filter_by_allowlist(&s(&["a", "b", "c"]), &[]); + assert_eq!(got, s(&["a", "b", "c"])); + } + + #[test] + fn substring_pattern_matches_case_insensitively() { + let got = filter_by_allowlist( + &s(&["claude-sonnet-4-6", "gpt-5.4-codex", "GEMINI-2.5-pro"]), + &s(&["sonnet", "GEMINI"]), + ); + // Output preserves pattern order, then per-pattern provider order. + assert_eq!(got, s(&["claude-sonnet-4-6", "GEMINI-2.5-pro"])); + } + + #[test] + fn glob_pattern_matches() { + let got = filter_by_allowlist( + &s(&["claude-opus-4-6", "claude-sonnet-4-6", "gpt-5.4"]), + &s(&["claude-*-4-6"]), + ); + assert_eq!(got, s(&["claude-opus-4-6", "claude-sonnet-4-6"])); + } + + #[test] + fn dedup_preserves_first_pattern_match_order() { + // Pattern A and B both match the same model — model only appears once, + // in the position dictated by the first pattern. + let got = filter_by_allowlist( + &s(&["claude-sonnet-4-6", "claude-opus-4-6"]), + &s(&["sonnet", "claude"]), + ); + assert_eq!(got, s(&["claude-sonnet-4-6", "claude-opus-4-6"])); + } + + #[test] + fn unmatched_patterns_are_silently_dropped() { + let got = filter_by_allowlist( + &s(&["gpt-5.4", "gpt-4o"]), + &s(&["does-not-exist", "gpt-5.4"]), + ); + assert_eq!(got, s(&["gpt-5.4"])); + } + + #[test] + fn parse_pattern_list_trims_and_drops_empty() { + let got = parse_pattern_list("sonnet , , gpt-* ,, claude "); + assert_eq!(got, s(&["sonnet", "gpt-*", "claude"])); + } + + #[test] + fn glob_match_question_mark_is_single_char() { + assert!(glob_match("a?c", "abc")); + assert!(!glob_match("a?c", "abbc")); + } +} diff --git a/src/tui/app/model_context.rs b/src/tui/app/model_context.rs index 5e2da43cb..e70849cc1 100644 --- a/src/tui/app/model_context.rs +++ b/src/tui/app/model_context.rs @@ -151,8 +151,31 @@ impl App { } pub(super) fn cycle_model(&mut self, direction: i8) { - let models = self.provider.available_models_for_switching(); + let provider_models = self.provider.available_models_for_switching(); + if provider_models.is_empty() { + self.push_display_message(DisplayMessage::error( + "Model switching is not available for this provider.", + )); + self.set_status_notice("Model switching not available"); + return; + } + + // Apply scoped-models allowlist (issue #26). If the user has + // configured a list, restrict cycling to entries matching it, + // preserving the allowlist's order so flips are deterministic. + let allowlist = crate::scoped_models::resolve_allowlist(); + let models = crate::scoped_models::filter_by_allowlist(&provider_models, &allowlist); if models.is_empty() { + // The allowlist filtered everything out — surface a helpful error + // instead of silently falling back to the unscoped list. + if !allowlist.is_empty() { + self.push_display_message(DisplayMessage::error(format!( + "Scoped models {:?} matched no available model. Edit `provider.scoped_models` or pass `--models `.", + allowlist + ))); + self.set_status_notice("No scoped models match"); + return; + } self.push_display_message(DisplayMessage::error( "Model switching is not available for this provider.", ));