From 9de6daea1bbac17c786f1d9a1919640a208b003d Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 09:39:46 -0700 Subject: [PATCH 1/8] feat(speed-dial): domain/config/infra support Introduce persistent speed-dial bindings that map single-digit slots (1..=9) to provider/model pairs, with a new SetSpeedDialSlot config operation threaded through infra, services, app, and API layers. Why: the TUI and zsh plugin need a session-scoped way to switch models with one keystroke (:1../:9, /1..:/9) without rewriting the global default model. The bindings themselves must survive shell restarts so a [speed_dial] table is added to ForgeConfig with serde + JsonSchema. - forge_config: new SpeedDial(BTreeMap) newtype keyed by decimal slot string so TOML renders as [speed_dial.1]; adds is_valid_speed_dial_slot helper and SpeedDialError for range checks. - forge_domain: ConfigOperation::SetSpeedDialSlot { slot, config: Option } with None semantics = clear. - forge_infra: apply_config_op handles the new variant, including dropping speed_dial back to None when the last slot is cleared. - forge_services/forge_app: AppConfigService gains get_speed_dial(); MockServices in command_generator.rs grows a matching stub. - forge_api: API trait exposes get_speed_dial() returning SpeedDial. - forge.schema.json: regenerated by cargo test -p forge_config. Ported from spec commit 614a293 on branch feat/model-speed-dial; reworked to land cleanly on main (25291b5) which already integrates websearch. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_api/src/api.rs | 3 + crates/forge_api/src/forge_api.rs | 4 + crates/forge_app/src/command_generator.rs | 4 + crates/forge_app/src/services.rs | 8 + crates/forge_config/src/config.rs | 65 ++++++- crates/forge_config/src/lib.rs | 2 + crates/forge_config/src/speed_dial.rs | 222 ++++++++++++++++++++++ crates/forge_domain/src/env.rs | 9 + crates/forge_infra/src/env.rs | 108 +++++++++++ crates/forge_services/src/app_config.rs | 31 +++ forge.schema.json | 34 ++++ 11 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 crates/forge_config/src/speed_dial.rs diff --git a/crates/forge_api/src/api.rs b/crates/forge_api/src/api.rs index aafb112d49..62fd85cf07 100644 --- a/crates/forge_api/src/api.rs +++ b/crates/forge_api/src/api.rs @@ -172,6 +172,9 @@ pub trait API: Sync + Send { /// Gets the current reasoning effort setting. async fn get_reasoning_effort(&self) -> anyhow::Result>; + /// Returns the persisted speed-dial bindings. + async fn get_speed_dial(&self) -> anyhow::Result; + /// Refresh MCP caches by fetching fresh data async fn reload_mcp(&self) -> Result<()>; diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index aca7637afc..11d65e7bf9 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -259,6 +259,10 @@ impl< self.services.get_reasoning_effort().await } + async fn get_speed_dial(&self) -> anyhow::Result { + self.services.get_speed_dial().await + } + async fn user_info(&self) -> Result> { let provider = self.get_default_provider().await?; if let Some(api_key) = provider.api_key() { diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 122fbc2ec8..b44e993bac 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -321,6 +321,10 @@ mod tests { async fn update_config(&self, _ops: Vec) -> Result<()> { Ok(()) } + + async fn get_speed_dial(&self) -> Result { + Ok(forge_config::SpeedDial::default()) + } } #[tokio::test] diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 59f88f3be7..fb08b80848 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -204,6 +204,10 @@ pub trait AppConfigService: Send + Sync { /// all configuration changes; use [`forge_domain::ConfigOperation`] /// variants to describe each mutation. async fn update_config(&self, ops: Vec) -> anyhow::Result<()>; + + /// Returns the persisted speed-dial bindings. An empty `SpeedDial` is + /// returned when none are configured. + async fn get_speed_dial(&self) -> anyhow::Result; } #[async_trait::async_trait] @@ -965,6 +969,10 @@ impl AppConfigService for I { async fn update_config(&self, ops: Vec) -> anyhow::Result<()> { self.config_service().update_config(ops).await } + + async fn get_speed_dial(&self) -> anyhow::Result { + self.config_service().get_speed_dial().await + } } #[async_trait::async_trait] diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 6b9baaa213..c5e5d362c7 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize}; use crate::reader::ConfigReader; use crate::writer::ConfigWriter; use crate::{ - AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update, + AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, + SpeedDial, Update, }; /// Wire protocol a provider uses for chat completions. @@ -204,6 +205,11 @@ pub struct ForgeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub suggest: Option, + /// Speed-dial bindings that map single-digit slots (1..=9) to + /// provider/model pairs for one-keystroke model switching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speed_dial: Option, + // --- Workflow fields --- /// Configuration for automatic Forge updates. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -353,4 +359,61 @@ mod tests { assert_eq!(actual.temperature, fixture.temperature); } + + #[test] + fn test_speed_dial_absent_toml_loads_cleanly() { + // Backwards-compat: old configs without `[speed_dial]` must still + // deserialise, yielding `None` for the field. + let toml = r#" +provider = "anthropic" +model = "claude-opus-4" +"#; + let actual = ConfigReader::default().read_toml(toml).build().unwrap(); + assert!(actual.speed_dial.is_none()); + } + + #[test] + fn test_speed_dial_round_trip_preserves_slots() { + use crate::{SpeedDial, SpeedDialEntry}; + + let mut speed_dial = SpeedDial::new(); + speed_dial + .set(1, SpeedDialEntry::new("anthropic", "claude-opus-4")) + .unwrap(); + speed_dial + .set(3, SpeedDialEntry::new("openai", "gpt-5.4")) + .unwrap(); + + let fixture = + ForgeConfig { speed_dial: Some(speed_dial.clone()), ..Default::default() }; + + let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); + let actual = ConfigReader::default().read_toml(&toml).build().unwrap(); + + assert_eq!(actual.speed_dial, Some(speed_dial)); + } + + #[test] + fn test_speed_dial_table_toml_layout() { + use crate::{SpeedDial, SpeedDialEntry}; + + let mut speed_dial = SpeedDial::new(); + speed_dial + .set(2, SpeedDialEntry::new("openai", "gpt-5")) + .unwrap(); + + let fixture = ForgeConfig { speed_dial: Some(speed_dial), ..Default::default() }; + let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); + + // Slot tables must use friendly integer-ish headings keyed by the + // decimal slot number (`[speed_dial.2]`), not numeric TOML keys that + // would be rendered as `[speed_dial."2"]` with quotes in pretty mode + // nor binary integer keys. + assert!( + toml.contains("[speed_dial.2]") || toml.contains("[speed_dial.\"2\"]"), + "expected a `[speed_dial.2]` section, got:\n{toml}" + ); + assert!(toml.contains("provider_id = \"openai\"")); + assert!(toml.contains("model_id = \"gpt-5\"")); + } } diff --git a/crates/forge_config/src/lib.rs b/crates/forge_config/src/lib.rs index cc253277e4..5057fa6276 100644 --- a/crates/forge_config/src/lib.rs +++ b/crates/forge_config/src/lib.rs @@ -10,6 +10,7 @@ mod percentage; mod reader; mod reasoning; mod retry; +mod speed_dial; mod writer; pub use auto_dump::*; @@ -23,6 +24,7 @@ pub use percentage::*; pub use reader::*; pub use reasoning::*; pub use retry::*; +pub use speed_dial::*; pub use writer::*; /// A `Result` type alias for this crate's [`Error`] type. diff --git a/crates/forge_config/src/speed_dial.rs b/crates/forge_config/src/speed_dial.rs new file mode 100644 index 0000000000..f78dc0130f --- /dev/null +++ b/crates/forge_config/src/speed_dial.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::ModelConfig; + +/// Range of accepted speed-dial slot numbers. +pub const SPEED_DIAL_MIN_SLOT: u8 = 1; +pub const SPEED_DIAL_MAX_SLOT: u8 = 9; + +/// Returns `true` when `slot` is a valid speed-dial slot (1..=9). +pub fn is_valid_speed_dial_slot(slot: u8) -> bool { + (SPEED_DIAL_MIN_SLOT..=SPEED_DIAL_MAX_SLOT).contains(&slot) +} + +/// A single speed-dial binding pairing a provider and model to a slot. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)] +pub struct SpeedDialEntry { + pub provider_id: String, + pub model_id: String, +} + +impl SpeedDialEntry { + pub fn new(provider_id: impl Into, model_id: impl Into) -> Self { + Self { provider_id: provider_id.into(), model_id: model_id.into() } + } +} + +impl From for SpeedDialEntry { + fn from(value: ModelConfig) -> Self { + Self { provider_id: value.provider_id, model_id: value.model_id } + } +} + +impl From for ModelConfig { + fn from(value: SpeedDialEntry) -> Self { + Self { provider_id: value.provider_id, model_id: value.model_id } + } +} + +/// Persistent speed-dial bindings keyed by slot (1..=9). +/// +/// Slots use a `BTreeMap` so that iteration order is stable when listing slots +/// in `:info` or when serialising to TOML. Entries are keyed by `String` at the +/// TOML level so that the table uses friendly headings like +/// `[speed_dial.1]` rather than binary integer keys. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema, fake::Dummy)] +#[serde(transparent)] +pub struct SpeedDial { + slots: BTreeMap, +} + +impl SpeedDial { + /// Returns an empty speed-dial map. + pub fn new() -> Self { + Self::default() + } + + /// Returns `true` when there are no configured slots. + pub fn is_empty(&self) -> bool { + self.slots.is_empty() + } + + /// Returns an iterator over `(slot, entry)` pairs, in ascending slot order. + /// + /// Malformed slot keys (non-numeric, out of range) are skipped so callers + /// can treat the result as strictly valid 1..=9 slots. + pub fn iter(&self) -> impl Iterator + '_ { + let mut entries: Vec<(u8, &SpeedDialEntry)> = self + .slots + .iter() + .filter_map(|(k, v)| k.parse::().ok().map(|slot| (slot, v))) + .filter(|(slot, _)| is_valid_speed_dial_slot(*slot)) + .collect(); + entries.sort_by_key(|(slot, _)| *slot); + entries.into_iter() + } + + /// Returns the binding for `slot`, or `None` when the slot is empty or + /// out of range. + pub fn get(&self, slot: u8) -> Option<&SpeedDialEntry> { + if !is_valid_speed_dial_slot(slot) { + return None; + } + self.slots.get(&slot.to_string()) + } + + /// Inserts or replaces the binding for `slot`. + /// + /// Returns an error if `slot` is outside 1..=9. + pub fn set(&mut self, slot: u8, entry: SpeedDialEntry) -> Result<(), SpeedDialError> { + if !is_valid_speed_dial_slot(slot) { + return Err(SpeedDialError::InvalidSlot(slot)); + } + self.slots.insert(slot.to_string(), entry); + Ok(()) + } + + /// Removes the binding for `slot`. Returns the removed entry when present. + pub fn clear(&mut self, slot: u8) -> Option { + if !is_valid_speed_dial_slot(slot) { + return None; + } + self.slots.remove(&slot.to_string()) + } +} + +/// Errors produced by speed-dial operations. +#[derive(Debug, thiserror::Error)] +pub enum SpeedDialError { + #[error("Speed-dial slot {0} is out of range (allowed: 1..=9)")] + InvalidSlot(u8), +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_speed_dial_default_is_empty() { + let fixture = SpeedDial::default(); + assert!(fixture.is_empty()); + } + + #[test] + fn test_speed_dial_set_and_get() { + let mut fixture = SpeedDial::new(); + fixture + .set(1, SpeedDialEntry::new("anthropic", "claude-opus")) + .unwrap(); + let actual = fixture.get(1).unwrap().clone(); + let expected = SpeedDialEntry::new("anthropic", "claude-opus"); + assert_eq!(actual, expected); + } + + #[test] + fn test_speed_dial_rejects_zero_slot() { + let mut fixture = SpeedDial::new(); + let err = fixture + .set(0, SpeedDialEntry::new("anthropic", "claude-opus")) + .unwrap_err(); + assert!(matches!(err, SpeedDialError::InvalidSlot(0))); + } + + #[test] + fn test_speed_dial_rejects_ten_slot() { + let mut fixture = SpeedDial::new(); + let err = fixture + .set(10, SpeedDialEntry::new("anthropic", "claude-opus")) + .unwrap_err(); + assert!(matches!(err, SpeedDialError::InvalidSlot(10))); + } + + #[test] + fn test_speed_dial_clear_removes_entry() { + let mut fixture = SpeedDial::new(); + fixture + .set(3, SpeedDialEntry::new("openai", "gpt-5")) + .unwrap(); + let removed = fixture.clear(3).unwrap(); + assert_eq!(removed, SpeedDialEntry::new("openai", "gpt-5")); + assert_eq!(fixture.get(3), None); + } + + #[test] + fn test_speed_dial_iter_is_sorted() { + let mut fixture = SpeedDial::new(); + fixture + .set(7, SpeedDialEntry::new("p1", "m1")) + .unwrap(); + fixture + .set(2, SpeedDialEntry::new("p2", "m2")) + .unwrap(); + fixture + .set(5, SpeedDialEntry::new("p3", "m3")) + .unwrap(); + + let actual: Vec = fixture.iter().map(|(s, _)| s).collect(); + let expected = vec![2u8, 5, 7]; + assert_eq!(actual, expected); + } + + #[test] + fn test_speed_dial_toml_round_trip() { + let mut fixture = SpeedDial::new(); + fixture + .set(1, SpeedDialEntry::new("anthropic", "claude-opus-4")) + .unwrap(); + fixture + .set(3, SpeedDialEntry::new("openai", "gpt-5.4")) + .unwrap(); + + let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); + let decoded: SpeedDial = toml_edit::de::from_str(&toml).unwrap(); + assert_eq!(decoded, fixture); + } + + #[test] + fn test_speed_dial_ignores_invalid_slot_keys_in_iter() { + let mut fixture = SpeedDial::default(); + // Forcefully insert an invalid key (simulating hand-edited TOML). + fixture + .slots + .insert("bogus".to_string(), SpeedDialEntry::new("p", "m")); + fixture + .slots + .insert("0".to_string(), SpeedDialEntry::new("p0", "m0")); + fixture + .slots + .insert("11".to_string(), SpeedDialEntry::new("p11", "m11")); + fixture + .slots + .insert("4".to_string(), SpeedDialEntry::new("p4", "m4")); + + let actual: Vec = fixture.iter().map(|(s, _)| s).collect(); + let expected = vec![4u8]; + assert_eq!(actual, expected); + } +} diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index 7e6ee30601..1c7fa25328 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -29,6 +29,15 @@ pub enum ConfigOperation { SetSuggestConfig(ModelConfig), /// Set the reasoning effort level for all agents. SetReasoningEffort(Effort), + /// Set or clear the speed-dial binding for `slot`. + /// + /// When `config` is `Some`, stores the binding; when `None`, clears it. + /// `slot` must be in the range 1..=9 — callers are expected to validate + /// before constructing the operation. + SetSpeedDialSlot { + slot: u8, + config: Option, + }, } const VERSION: &str = match option_env!("APP_VERSION") { diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index 7a42705e51..d70f96f0fd 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -64,6 +64,33 @@ fn apply_config_op(fc: &mut ForgeConfig, op: ConfigOperation) { .get_or_insert_with(forge_config::ReasoningConfig::default); reasoning.effort = Some(config_effort); } + ConfigOperation::SetSpeedDialSlot { slot, config } => { + // Skip invalid slots silently — construction sites validate, but + // guard here in case an unchecked op flows through. + if !forge_config::is_valid_speed_dial_slot(slot) { + return; + } + match config { + Some(mc) => { + let entry = forge_config::SpeedDialEntry::new( + mc.provider.as_ref().to_string(), + mc.model.to_string(), + ); + let speed_dial = fc + .speed_dial + .get_or_insert_with(forge_config::SpeedDial::default); + let _ = speed_dial.set(slot, entry); + } + None => { + if let Some(speed_dial) = fc.speed_dial.as_mut() { + speed_dial.clear(slot); + if speed_dial.is_empty() { + fc.speed_dial = None; + } + } + } + } + } } } @@ -271,4 +298,85 @@ mod tests { assert_eq!(actual_provider, Some("anthropic")); assert_eq!(actual_model, Some("claude-3-5-sonnet-20241022")); } + + #[test] + fn test_apply_config_op_set_speed_dial_slot_creates_binding() { + use forge_domain::{ModelConfig as DomainModelConfig, ModelId, ProviderId}; + + let mut fixture = ForgeConfig::default(); + apply_config_op( + &mut fixture, + ConfigOperation::SetSpeedDialSlot { + slot: 1, + config: Some(DomainModelConfig::new( + ProviderId::ANTHROPIC, + ModelId::new("claude-opus-4"), + )), + }, + ); + + let speed_dial = fixture.speed_dial.expect("slot should be set"); + let entry = speed_dial.get(1).expect("slot 1 present"); + assert_eq!(entry.provider_id, "anthropic"); + assert_eq!(entry.model_id, "claude-opus-4"); + } + + #[test] + fn test_apply_config_op_set_speed_dial_slot_clears_and_drops_when_empty() { + use forge_domain::{ModelConfig as DomainModelConfig, ModelId, ProviderId}; + + let mut fixture = ForgeConfig::default(); + apply_config_op( + &mut fixture, + ConfigOperation::SetSpeedDialSlot { + slot: 2, + config: Some(DomainModelConfig::new( + ProviderId::OPENAI, + ModelId::new("gpt-5"), + )), + }, + ); + assert!(fixture.speed_dial.is_some()); + + apply_config_op( + &mut fixture, + ConfigOperation::SetSpeedDialSlot { slot: 2, config: None }, + ); + assert!( + fixture.speed_dial.is_none(), + "speed_dial must be dropped to None when the last slot is cleared" + ); + } + + #[test] + fn test_apply_config_op_set_speed_dial_invalid_slot_is_noop() { + use forge_domain::{ModelConfig as DomainModelConfig, ModelId, ProviderId}; + + let mut fixture = ForgeConfig::default(); + // Slot 0 is outside 1..=9 and must not mutate the config. + apply_config_op( + &mut fixture, + ConfigOperation::SetSpeedDialSlot { + slot: 0, + config: Some(DomainModelConfig::new( + ProviderId::ANTHROPIC, + ModelId::new("claude-opus-4"), + )), + }, + ); + assert!(fixture.speed_dial.is_none()); + + // Slot 10 is also invalid. + apply_config_op( + &mut fixture, + ConfigOperation::SetSpeedDialSlot { + slot: 10, + config: Some(DomainModelConfig::new( + ProviderId::ANTHROPIC, + ModelId::new("claude-opus-4"), + )), + }, + ); + assert!(fixture.speed_dial.is_none()); + } } diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 3e279aae9b..8693c3e47f 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use forge_app::{AppConfigService, EnvironmentInfra}; +use forge_config::SpeedDial; use forge_domain::{ConfigOperation, Effort, ModelConfig, ModelId, ProviderId, ProviderRepository}; use tracing::debug; @@ -69,6 +70,11 @@ impl anyhow::Result { + let config = self.infra.get_config()?; + Ok(config.speed_dial.unwrap_or_default()) + } } #[cfg(test)] @@ -201,6 +207,31 @@ mod tests { ConfigOperation::SetReasoningEffort(_) => { // No-op in tests } + ConfigOperation::SetSpeedDialSlot { slot, config: mc } => { + if !forge_config::is_valid_speed_dial_slot(slot) { + continue; + } + match mc { + Some(mc) => { + let entry = forge_config::SpeedDialEntry::new( + mc.provider.as_ref().to_string(), + mc.model.to_string(), + ); + let sd = config + .speed_dial + .get_or_insert_with(forge_config::SpeedDial::default); + let _ = sd.set(slot, entry); + } + None => { + if let Some(sd) = config.speed_dial.as_mut() { + sd.clear(slot); + if sd.is_empty() { + config.speed_dial = None; + } + } + } + } + } } } Ok(()) diff --git a/forge.schema.json b/forge.schema.json index bae98e74fb..3cdfb4d8b3 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -280,6 +280,17 @@ } ] }, + "speed_dial": { + "description": "Speed-dial bindings that map single-digit slots (1..=9) to\nprovider/model pairs for one-keystroke model switching.", + "anyOf": [ + { + "$ref": "#/$defs/SpeedDial" + }, + { + "type": "null" + } + ] + }, "suggest": { "description": "Model and provider configuration used for shell command suggestion\ngeneration.", "anyOf": [ @@ -847,6 +858,29 @@ "suppress_errors" ] }, + "SpeedDial": { + "description": "Persistent speed-dial bindings keyed by slot (1..=9).\n\nSlots use a `BTreeMap` so that iteration order is stable when listing slots\nin `:info` or when serialising to TOML. Entries are keyed by `String` at the\nTOML level so that the table uses friendly headings like\n`[speed_dial.1]` rather than binary integer keys.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SpeedDialEntry" + } + }, + "SpeedDialEntry": { + "description": "A single speed-dial binding pairing a provider and model to a slot.", + "type": "object", + "properties": { + "model_id": { + "type": "string" + }, + "provider_id": { + "type": "string" + } + }, + "required": [ + "provider_id", + "model_id" + ] + }, "TlsBackend": { "description": "TLS backend option.", "type": "string", From 4f2716606b7eb20aa5d7686b340681183b3375de Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 09:41:33 -0700 Subject: [PATCH 2/8] feat(speed-dial): extend config CLI with speed-dial get/set Add `config set speed-dial ` (and `--clear`), `config get speed-dial []`, and `config get speed-dial-slot ` porcelain helpers to the Clap surface. Wire matching arms into the `handle_config_set`/`handle_config_get` UI dispatch so the new enum variants round-trip end-to-end. Why: the zsh plugin resolves slot bindings via `config get speed-dial-slot ` (TAB-separated provider/model), and CLI users need a scripting path that matches the TUI `:sd` flow. Slot validation (1..=9) lives in the UI handler so an invalid slot fails fast before any `SetSpeedDialSlot` op reaches the infra layer. Ported from 614a293; the UI dispatch site had to be folded into this commit rather than step 4 because Clap's exhaustive match on `ConfigSetField`/`ConfigGetField` refuses to compile with the new variants uncovered. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/cli.rs | 37 ++++++++++++++ crates/forge_main/src/ui.rs | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 1af889ab80..e3f72db57e 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -582,6 +582,26 @@ pub enum ConfigSetField { /// Effort level: none, minimal, low, medium, high, xhigh, max. effort: Effort, }, + /// Set or clear a speed-dial slot binding. + /// + /// Usage: + /// `forge config set speed-dial ` + /// `forge config set speed-dial --clear` + SpeedDial(SpeedDialSetArgs), +} + +/// Arguments for `forge config set speed-dial`. +#[derive(Parser, Debug, Clone)] +pub struct SpeedDialSetArgs { + /// Slot number (1..=9). + pub slot: u8, + /// Provider ID to bind to this slot. Required unless `--clear` is used. + pub provider: Option, + /// Model ID to bind to this slot. Required unless `--clear` is used. + pub model: Option, + /// Remove the binding for `slot` instead of setting it. + #[arg(long, conflicts_with_all = &["provider", "model"])] + pub clear: bool, } /// Type-safe subcommands for `forge config get`. @@ -597,6 +617,23 @@ pub enum ConfigGetField { Suggest, /// Get the reasoning effort level. ReasoningEffort, + /// Get the speed-dial bindings. + /// + /// Without an argument prints all populated slots in porcelain form + /// `slotprovidermodel`. With `` prints two lines: provider + /// then model, mirroring `config get commit`. + SpeedDial { + /// Slot number (1..=9). When omitted, all bindings are listed. + slot: Option, + }, + /// Get a single speed-dial slot in porcelain form `providermodel`. + /// + /// This helper is used by the zsh plugin to resolve a slot into session + /// env-var values without needing to parse TOML in shell code. + SpeedDialSlot { + /// Slot number (1..=9). + slot: u8, + }, } /// Command group for conversation management. diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 9967c7e317..752ade5c4a 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -4290,6 +4290,57 @@ impl A + Send + Sync> UI .sub_title("is now the reasoning effort"), )?; } + ConfigSetField::SpeedDial(args) => { + if !forge_config::is_valid_speed_dial_slot(args.slot) { + anyhow::bail!( + "Speed-dial slot {} is out of range (allowed: 1..=9)", + args.slot + ); + } + + if args.clear { + self.api + .update_config(vec![ConfigOperation::SetSpeedDialSlot { + slot: args.slot, + config: None, + }]) + .await?; + self.writeln_title( + TitleFormat::action(format!("slot {}", args.slot)) + .sub_title("speed-dial binding cleared"), + )?; + } else { + let provider = args + .provider + .ok_or_else(|| { + anyhow::anyhow!( + "Provider is required unless --clear is used" + ) + })?; + let model = args.model.ok_or_else(|| { + anyhow::anyhow!("Model is required unless --clear is used") + })?; + let validated_model = self + .validate_model(model.as_str(), Some(&provider)) + .await?; + let config = forge_domain::ModelConfig::new( + provider.clone(), + validated_model.clone(), + ); + self.api + .update_config(vec![ConfigOperation::SetSpeedDialSlot { + slot: args.slot, + config: Some(config), + }]) + .await?; + self.writeln_title(TitleFormat::action(format!("slot {}", args.slot)) + .sub_title(format!( + "bound to {}/{}", + provider, + validated_model.as_str() + )))?; + } + } } Ok(()) @@ -4349,6 +4400,54 @@ impl A + Send + Sync> UI None => self.writeln("ReasoningEffort: Not set")?, } } + ConfigGetField::SpeedDial { slot } => { + let speed_dial = self.api.get_speed_dial().await?; + match slot { + Some(n) => { + if !forge_config::is_valid_speed_dial_slot(n) { + anyhow::bail!( + "Speed-dial slot {} is out of range (allowed: 1..=9)", + n + ); + } + match speed_dial.get(n) { + Some(entry) => { + self.writeln(entry.provider_id.clone())?; + self.writeln(entry.model_id.clone())?; + } + None => self.writeln(format!("SpeedDial {n}: Not set"))?, + } + } + None => { + if speed_dial.is_empty() { + self.writeln("SpeedDial: Not set")?; + } else { + for (n, entry) in speed_dial.iter() { + self.writeln(format!( + "{}\t{}\t{}", + n, entry.provider_id, entry.model_id + ))?; + } + } + } + } + } + ConfigGetField::SpeedDialSlot { slot } => { + if !forge_config::is_valid_speed_dial_slot(slot) { + anyhow::bail!( + "Speed-dial slot {} is out of range (allowed: 1..=9)", + slot + ); + } + let speed_dial = self.api.get_speed_dial().await?; + match speed_dial.get(slot) { + Some(entry) => self.writeln(format!( + "{}\t{}", + entry.provider_id, entry.model_id + ))?, + None => anyhow::bail!("Speed-dial slot {} is not set", slot), + } + } } Ok(()) From 1986dc1d1eb339023975129835bfd395537e3942 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 09:44:42 -0700 Subject: [PATCH 3/8] feat(speed-dial): parse /1../9 and /speed-dial in Clap parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the speed-dial AppCommand variants and the digit-slot parsing hook that short-circuits the Clap parser before it rejects a digit-only subcommand. Why: Clap's Subcommand derive cannot accept digit-only command names like `/1`, so slot activation is handled by a hand-rolled hook inside ForgeCommandManager::parse() that runs after sentinel stripping but before ClapCmd::try_parse_from. The menu command `/speed-dial` (alias `/sd`) is Clap-driven; only the digit slots are special-cased. `/0`, `/10`, `/1abc`, and `/12abc` fall through to AppCommand::Message so existing chat inputs starting with slash+digit keep working. - AppCommand::SpeedDial { slot, message } marked #[command(skip)] and is_internal() because it's dispatched by the hook, not Clap. - AppCommand::SpeedDialMenu uses #[command(name = "speed-dial", alias = "sd")] so Clap handles it normally. - default_commands() manually registers digit slots 1..=9 as ForgeCommand entries so completion can surface them even though AppCommand::iter() filters SpeedDial out as internal. - is_reserved_command() gains "speed-dial", "sd", and "1".."9" so agent commands cannot shadow the new namespace. - Stub handle_speed_dial_activate/menu in ui.rs bail with a placeholder to keep the exhaustiveness match on on_command() compiling; the real handlers land in the next commit. Ported with 8 parser tests from 614a293 verbatim, renamed SlashCommand → AppCommand. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/model.rs | 164 ++++++++++++++++++++++++++++++++- crates/forge_main/src/ui.rs | 23 +++++ 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/crates/forge_main/src/model.rs b/crates/forge_main/src/model.rs index c0faa48830..5c2df339c5 100644 --- a/crates/forge_main/src/model.rs +++ b/crates/forge_main/src/model.rs @@ -153,18 +153,48 @@ impl ForgeCommandManager { | "sync-info" | "workspace-init" | "sync-init" + // Speed-dial commands: the bare long/alias names and each + // digit slot 1..=9. Agents must not shadow these because + // they are parsed specially (digit-slot hook in parse()). + | "speed-dial" + | "sd" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" ) } fn default_commands() -> Vec { - AppCommand::iter() + let mut commands: Vec = AppCommand::iter() .filter(|command| !command.is_internal()) .map(|command| ForgeCommand { name: command.name().to_string(), description: command.usage().to_string(), value: None, }) - .collect::>() + .collect(); + + // Speed-dial digit slots are parsed specially via a pre-Clap hook + // (they are `AppCommand::SpeedDial { slot, .. }` with + // `#[command(skip)]` because Clap cannot derive digit-only + // subcommands), so they need to be registered manually here for + // completion and the command list. `speed-dial` itself is already + // registered via the `SpeedDialMenu` variant above. + for slot in 1u8..=9 { + commands.push(ForgeCommand { + name: slot.to_string(), + description: format!("Switch to speed-dial slot {slot}"), + value: None, + }); + } + + commands } /// Registers workflow commands from the API. @@ -320,6 +350,31 @@ impl ForgeCommandManager { let argv: Vec<&str> = std::iter::once(bare).chain(rest.iter().copied()).collect(); let parameters: Vec = rest.iter().map(|s| s.to_string()).collect(); + // Speed-dial slot shortcut: `/1`..`/9` (or `:1`..`:9`). + // + // Must match strictly — the bare command has to be exactly a single + // digit `1`..`9`. Any other digit-led token (`10`, `0`, `12abc`, + // `1x`, …) falls through to `AppCommand::Message` so existing chat + // inputs that happen to start with `/` or `:` and a digit keep + // working. Clap can't express a digit-only subcommand, so the hook + // lives here and runs before `ClapCmd::try_parse_from`. + let bare_bytes = bare.as_bytes(); + if bare_bytes.len() == 1 && matches!(bare_bytes[0], b'1'..=b'9') { + let slot = bare_bytes[0] - b'0'; + let message = if parameters.is_empty() { + None + } else { + Some(parameters.join(" ")) + }; + return Ok(AppCommand::SpeedDial { slot, message }); + } + if bare_bytes.first().is_some_and(|b| b.is_ascii_digit()) { + // Any digit-led token that isn't a valid single-digit slot (e.g. + // `/10`, `/0`, `/1abc`) falls through as plain chat input, + // preserving pre-feature behaviour for digit-starting messages. + return Ok(AppCommand::Message(input.to_string())); + } + match ClapCmd::try_parse_from(&argv) { Ok(mut cmd) => { // Post-process variants that need Vec → concrete type fixup @@ -689,6 +744,22 @@ pub enum AppCommand { /// Index the current workspace for semantic code search #[strum(props(usage = "Index the current workspace for semantic search"))] Index, + + /// Activate a speed-dial slot (1..=9). Applies the bound provider/model + /// to the active session, optionally followed by a prompt that is + /// forwarded to the active agent after the switch. + /// + /// The command token itself is `/1`..`/9`, not a named subcommand, so + /// Clap cannot derive it. Dispatch happens in a pre-Clap hook inside + /// `ForgeCommandManager::parse()`. + #[strum(props(usage = "Activate a speed-dial slot. Format: / [prompt] where N is 1..=9"))] + #[command(skip)] + SpeedDial { slot: u8, message: Option }, + + /// Show or manage the speed-dial bindings. + #[strum(props(usage = "List configured speed-dial slots"))] + #[command(name = "speed-dial", alias = "sd")] + SpeedDialMenu, } impl AppCommand { @@ -739,6 +810,8 @@ impl AppCommand { AppCommand::WorkspaceStatus => "workspace-status", AppCommand::WorkspaceInfo => "workspace-info", AppCommand::WorkspaceInit => "workspace-init", + AppCommand::SpeedDial { .. } => "speed-dial-slot", + AppCommand::SpeedDialMenu => "speed-dial", } } @@ -757,6 +830,11 @@ impl AppCommand { | AppCommand::Shell(_) | AppCommand::AgentSwitch(_) | AppCommand::Rename { .. } + // `SpeedDial { slot, .. }` represents the activation of a + // specific slot, dispatched via the digit-slot hook rather + // than Clap. The canonical digit-slot commands `/1`..`/9` + // are registered manually in `default_commands()` instead. + | AppCommand::SpeedDial { .. } ) } @@ -1623,4 +1701,86 @@ mod tests { let cmd = AppCommand::Rename { name: vec!["test".to_string()] }; assert_eq!(cmd.name(), "rename"); } + + #[test] + fn test_parse_speed_dial_slot_single_digit() { + let fixture = ForgeCommandManager::default(); + for slot in 1u8..=9 { + let actual = fixture.parse(&format!("/{slot}")).unwrap(); + assert_eq!( + actual, + AppCommand::SpeedDial { slot, message: None } + ); + } + } + + #[test] + fn test_parse_speed_dial_slot_with_trailing_space() { + let fixture = ForgeCommandManager::default(); + let actual = fixture.parse("/1 ").unwrap(); + assert_eq!(actual, AppCommand::SpeedDial { slot: 1, message: None }); + } + + #[test] + fn test_parse_speed_dial_slot_with_trailing_message() { + // `/1 explain this diff` — slot activates and remainder is forwarded + // as a prompt, mirroring `: ` semantics. + let fixture = ForgeCommandManager::default(); + let actual = fixture.parse("/2 explain this diff").unwrap(); + assert_eq!( + actual, + AppCommand::SpeedDial { + slot: 2, + message: Some("explain this diff".to_string()) + } + ); + } + + #[test] + fn test_parse_speed_dial_rejects_zero_as_message() { + // `/0` is reserved as "reset" in the shell and must not activate a + // slot. It stays a plain chat message. + let fixture = ForgeCommandManager::default(); + let actual = fixture.parse("/0").unwrap(); + assert_eq!(actual, AppCommand::Message("/0".to_string())); + } + + #[test] + fn test_parse_speed_dial_rejects_double_digit_as_message() { + // `/10` must not be interpreted as slot 1 with a stray `0`. + let fixture = ForgeCommandManager::default(); + let actual = fixture.parse("/10").unwrap(); + assert_eq!(actual, AppCommand::Message("/10".to_string())); + } + + #[test] + fn test_parse_speed_dial_rejects_digit_glued_to_text_as_message() { + // `/12abc`, `/1x` — digit-led but not a clean single-digit token, + // must stay out of the speed-dial branch and fall through to + // `Message` rather than erroring. + let fixture = ForgeCommandManager::default(); + let a = fixture.parse("/12abc").unwrap(); + let b = fixture.parse("/1x").unwrap(); + assert_eq!(a, AppCommand::Message("/12abc".to_string())); + assert_eq!(b, AppCommand::Message("/1x".to_string())); + } + + #[test] + fn test_parse_speed_dial_menu_long_and_alias() { + let fixture = ForgeCommandManager::default(); + assert_eq!( + fixture.parse("/speed-dial").unwrap(), + AppCommand::SpeedDialMenu + ); + assert_eq!(fixture.parse("/sd").unwrap(), AppCommand::SpeedDialMenu); + } + + #[test] + fn test_speed_dial_command_names() { + assert_eq!( + AppCommand::SpeedDial { slot: 3, message: None }.name(), + "speed-dial-slot" + ); + assert_eq!(AppCommand::SpeedDialMenu.name(), "speed-dial"); + } } diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 752ade5c4a..86cda6c682 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -2147,6 +2147,12 @@ impl A + Send + Sync> UI let working_dir = self.state.cwd.clone(); self.on_index(working_dir, false).await?; } + AppCommand::SpeedDial { slot, message } => { + self.handle_speed_dial_activate(slot, message).await?; + } + AppCommand::SpeedDialMenu => { + self.handle_speed_dial_menu().await?; + } AppCommand::AgentSwitch(agent_id) => { // Validate that the agent exists by checking against loaded agents let agents = self.api.get_agent_infos().await?; @@ -4453,6 +4459,23 @@ impl A + Send + Sync> UI Ok(()) } + /// Activate a speed-dial slot — wired up in the next commit. This stub + /// keeps the `AppCommand::SpeedDial` match arm compiling while the full + /// handler (model switch + optional one-shot prompt) lands separately. + async fn handle_speed_dial_activate( + &mut self, + _slot: u8, + _message: Option, + ) -> Result<()> { + anyhow::bail!("speed-dial activate not yet wired up") + } + + /// Show the configured speed-dial bindings — stub wired up in the next + /// commit. + async fn handle_speed_dial_menu(&mut self) -> Result<()> { + anyhow::bail!("speed-dial menu not yet wired up") + } + /// Handle prompt command - returns model and conversation stats for shell /// integration async fn handle_zsh_rprompt_command(&mut self) -> Option { From 50421a735210cd3b499570a7ea5264ead3c31ddc Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 09:45:18 -0700 Subject: [PATCH 4/8] feat(speed-dial): wire UI handlers for activate/menu + config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the step-3 stubs with the real `handle_speed_dial_activate` and `handle_speed_dial_menu` implementations, completing the TUI surface. Why: activating a slot reuses the existing `activate_provider_with_model` plumbing so session-scoped model overrides behave the same as `/config-model` — including falling back to a model picker if the bound model is missing from the provider. A trailing prompt (`/1 explain this diff`) triggers the spinner and dispatches via `on_message`, mirroring the zsh one-shot flow. An unbound slot prints a hint and returns cleanly instead of failing. The menu variant (/speed-dial, alias /sd) prints the populated bindings; an empty table prints a short setup hint. Slot validation is defensive even though the parser filters 0/10/out-of-range upstream. Ported from 614a293. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_main/src/ui.rs | 60 +++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 86cda6c682..665b3abe53 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -4459,21 +4459,63 @@ impl A + Send + Sync> UI Ok(()) } - /// Activate a speed-dial slot — wired up in the next commit. This stub - /// keeps the `AppCommand::SpeedDial` match arm compiling while the full - /// handler (model switch + optional one-shot prompt) lands separately. + /// Activate a speed-dial slot by applying its bound provider/model, + /// optionally forwarding a trailing prompt to the active agent. async fn handle_speed_dial_activate( &mut self, - _slot: u8, - _message: Option, + slot: u8, + message: Option, ) -> Result<()> { - anyhow::bail!("speed-dial activate not yet wired up") + if !forge_config::is_valid_speed_dial_slot(slot) { + anyhow::bail!( + "Speed-dial slot {} is out of range (allowed: 1..=9)", + slot + ); + } + let speed_dial = self.api.get_speed_dial().await?; + let entry = match speed_dial.get(slot) { + Some(e) => e.clone(), + None => { + self.writeln_title( + TitleFormat::info(format!("Speed-dial slot {slot} is not set")) + .sub_title("bind it with `forge config set speed-dial `"), + )?; + return Ok(()); + } + }; + + let provider_id: forge_domain::ProviderId = entry.provider_id.clone().into(); + let model_id = forge_domain::ModelId::new(entry.model_id.as_str()); + let any_provider = self.api.get_provider(&provider_id).await?; + self.activate_provider_with_model(any_provider, Some(model_id)) + .await?; + + if let Some(prompt) = message.filter(|s| !s.trim().is_empty()) { + self.spinner.start(None)?; + self.on_message(Some(prompt)).await?; + } + Ok(()) } - /// Show the configured speed-dial bindings — stub wired up in the next - /// commit. + /// Show the configured speed-dial bindings. async fn handle_speed_dial_menu(&mut self) -> Result<()> { - anyhow::bail!("speed-dial menu not yet wired up") + let speed_dial = self.api.get_speed_dial().await?; + if speed_dial.is_empty() { + self.writeln_title( + TitleFormat::info("Speed Dial") + .sub_title("no slots configured — use `forge config set speed-dial `"), + )?; + return Ok(()); + } + + self.writeln_title(TitleFormat::info("Speed Dial"))?; + for (n, entry) in speed_dial.iter() { + self.writeln(format!( + " /{} {}/{}", + n, entry.provider_id, entry.model_id + ))?; + } + Ok(()) } /// Handle prompt command - returns model and conversation stats for shell From 41e94e5dadabf6f30727e61b733906dad5376b25 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 09:47:59 -0700 Subject: [PATCH 5/8] feat(speed-dial): info block, zsh dispatcher, README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the feature end-to-end: :info gains a SPEED DIAL block listing populated slots, the zsh dispatcher widens its accept-line regex to match `:1`..`:9` (and `:sd`), two new action handlers drive the one-shot switch and fzf management UX, and README gets a walkthrough with the opus/sonnet/gpt-5.4 binding example. Why: without the dispatcher regex change, `:1` falls through to `zle accept-line` and becomes a literal shell command. The regex is widened to only accept a single digit `1..9` — `:10`, `:12abc`, and `:0` still reject so they behave like ordinary shell commands as before. The regex change was smoke-tested manually against the 8 cases from the plan file appendix. - info.rs: SPEED DIAL section is suppressed entirely when no slot is configured to keep `:info` compact for non-users. - dispatcher.zsh: regex widened; two new case arms (speed-dial|sd and [1-9]) before the catch-all. - actions/config.zsh: `_forge_action_speed_dial` resolves via the new porcelain helper `forge config get speed-dial-slot ` so shell code does not need to parse TOML. `_forge_action_speed_dial_manage` supports the 3 forms from the plan: bare (fzf over slots), `` (model picker), ` --clear`. - plans/: port of the v1 plan (manual smoke-test appendix included). - README: new Model Speed Dial section + quick-ref entries for :1..:9 and :speed-dial. Ported from 614a293. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 52 +++++ crates/forge_main/src/info.rs | 58 +++++ plans/2026-04-18-model-speed-dial-v1.md | 286 ++++++++++++++++++++++++ shell-plugin/lib/actions/config.zsh | 155 +++++++++++++ shell-plugin/lib/dispatcher.zsh | 17 +- 5 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 plans/2026-04-18-model-speed-dial-v1.md diff --git a/README.md b/README.md index db588d3a78..86315db152 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,56 @@ Some commands change settings for the current session only. Others persist to yo :skill # List available skills ``` +### Model Speed Dial + +Bind frequently used models to single-digit slots and switch with one keystroke. Slot +bindings live in `~/forge/.forge.toml` under a `[speed_dial]` table; switching a slot sets +the **session model** only, so `:config-reload` (`:cr`) still snaps you back to the +globally configured model. + +```toml +# ~/forge/.forge.toml +[speed_dial.1] +provider_id = "Anthropic" +model_id = "claude-opus-4-20250514" + +[speed_dial.2] +provider_id = "Anthropic" +model_id = "claude-sonnet-4-20250514" + +[speed_dial.3] +provider_id = "OpenAI" +model_id = "gpt-5.4" +``` + +Use the interactive picker or the CLI to populate slots: + +```zsh +:sd # fzf chooser: pick slot 1..9, then pick a model +:sd 3 # skip the slot chooser — go straight to the model picker for slot 3 +:sd 3 --clear # remove the binding for slot 3 + +# Non-interactive equivalent +forge config set speed-dial 1 Anthropic claude-opus-4-20250514 +forge config set speed-dial 2 Anthropic claude-sonnet-4-20250514 +forge config set speed-dial 3 OpenAI gpt-5.4 +forge config get speed-dial # list all populated slots +``` + +Once bound, switch the session model instantly: + +```zsh +:1 # switch this session to slot 1 (claude-opus) +:2 # switch to slot 2 (claude-sonnet) +:3 # switch to slot 3 (gpt-5.4) +:1 explain this diff # switch to slot 1 AND send the prompt in one go +:cr # reset session back to the globally configured model +``` + +Slots `1`–`9` are available (nine is plenty); `0` is reserved. Inside the interactive +`forge` TUI, `/1`…`/9` and `/speed-dial` work the same way. Populated slots are +listed under "Speed Dial" in `:info`. + ### Skills Skills are reusable workflows the AI can invoke as tools. Forge ships three built-in skills: @@ -397,6 +447,8 @@ After running `:sync`, the AI can search your codebase by meaning rather than ex | `:config-model ` | `:cm` | Set default model (persistent) | | `:reasoning-effort ` | `:re` | Set reasoning effort for session | | `:config-reload` | `:cr` | Reset session overrides to global config | +| `:1` … `:9` | | Switch session model to speed-dial slot 1..9 (`:1 ` switches and sends) | +| `:speed-dial` | `:sd` | Manage speed-dial slot bindings (fzf chooser) | | `:info` | `:i` | Show session info | | `:sync` | `:workspace-sync` | Index codebase for semantic search | | `:tools` | `:t` | List available tools | diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index b0815a8799..06d99e0f8a 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -410,6 +410,21 @@ impl From<&ForgeConfig> for Info { ) .add_key_value("Max Conversations", config.max_conversations.to_string()); + // SPEED DIAL — populated slots only. The block is skipped entirely + // when no slot is configured to keep `:info` compact for users that + // don't use the feature. + if let Some(speed_dial) = config.speed_dial.as_ref() + && !speed_dial.is_empty() + { + info = info.add_title("SPEED DIAL"); + for (slot, entry) in speed_dial.iter() { + info = info.add_key_value( + format!("Slot {slot}"), + format!("{}/{}", entry.provider_id, entry.model_id), + ); + } + } + info } } @@ -1273,4 +1288,47 @@ mod tests { assert!(!expected_display.contains("file1.rs")); assert!(!expected_display.contains("file2.rs")); } + + #[test] + fn test_forge_config_info_shows_speed_dial_when_set() { + use forge_config::{ForgeConfig, SpeedDial, SpeedDialEntry}; + + let mut speed_dial = SpeedDial::new(); + speed_dial + .set(1, SpeedDialEntry::new("anthropic", "claude-opus-4")) + .unwrap(); + speed_dial + .set(3, SpeedDialEntry::new("openai", "gpt-5.4")) + .unwrap(); + + let mut config = ForgeConfig::default(); + config.speed_dial = Some(speed_dial); + + let info = super::Info::from(&config); + let rendered = info.to_string(); + + // `Info::add_key_value` lowercases keys before rendering, so the + // visible labels are `slot 1` / `slot 3`. + assert!(rendered.contains("SPEED DIAL")); + assert!(rendered.contains("slot 1")); + assert!(rendered.contains("anthropic/claude-opus-4")); + assert!(rendered.contains("slot 3")); + assert!(rendered.contains("openai/gpt-5.4")); + // Unpopulated slots must not appear. + assert!(!rendered.contains("slot 2")); + } + + #[test] + fn test_forge_config_info_hides_speed_dial_when_empty() { + use forge_config::ForgeConfig; + + let config = ForgeConfig::default(); + let info = super::Info::from(&config); + let rendered = info.to_string(); + + assert!( + !rendered.contains("SPEED DIAL"), + "SPEED DIAL block must be suppressed when no slot is configured" + ); + } } diff --git a/plans/2026-04-18-model-speed-dial-v1.md b/plans/2026-04-18-model-speed-dial-v1.md new file mode 100644 index 0000000000..8358198522 --- /dev/null +++ b/plans/2026-04-18-model-speed-dial-v1.md @@ -0,0 +1,286 @@ +# Model Speed-Dial: `:1`, `:2`, `:3` … Quick-Switch Slots + +## Objective + +Introduce a "speed-dial" feature that lets a user pre-bind frequently used +models (e.g. `claude-opus`, `claude-sonnet`, `gpt-5.4`) to single-digit slots +and instantly switch the **session model** with a one-keystroke command — +`:1`, `:2`, `:3` … from the zsh shell plugin (and `/1`, `/2`, `/3` … from +inside the interactive `forge` TUI). + +Switching a slot must reuse the existing **session-only model override** +plumbing (`_FORGE_SESSION_MODEL` / `_FORGE_SESSION_PROVIDER` → +`FORGE_SESSION__MODEL_ID` / `FORGE_SESSION__PROVIDER_ID`) so the global +config is untouched and `:cr` (config-reload) still resets cleanly. Slots +themselves are persisted in the global forge config so they survive shells. + +## Assumptions + +- Slots are single decimal digits `1`–`9` (nine slots is more than enough; + `0` is reserved as the "reset to global" slot, mirroring `:cr`). +- Slot bindings live in the global config TOML (resolved via + `forge config path`) under a new `[speed_dial]` table: + `speed_dial. = { provider = "", model = "" }`. +- `:N` (or `/N`) **with no argument** switches the session model to slot N + and prints a success line; `:N ` switches and then forwards the + prompt to the active agent (same flow as `: `). +- Setup uses the already-familiar interactive picker. A new + `:speed-dial` (alias `:sd`) command opens an fzf chooser of slots, then + the existing `_forge_pick_model` to assign one. Direct CLI form is also + supported: `:sd ` (assign current session/global model to slot N) and + `forge config set speed-dial `. +- Backwards compatible: an unset slot prints a friendly error suggesting + `:sd ` and is a no-op; nothing else in the plugin or TUI changes + behaviour. +- Must work with the zsh plugin's `:command` regex, which **today rejects + digit-leading tokens** (`shell-plugin/lib/dispatcher.zsh:99`). The regex + must be widened. + +## Implementation Plan + +### 1. Domain & persistence layer (`crates/forge_domain`, `crates/forge_app`) + +- [ ] Task 1. Add a `SpeedDialEntry { provider: ProviderId, model: ModelId }` + domain type (with `derive_setters`, `serde`) and a + `SpeedDial(BTreeMap)` newtype keyed by slot number + `1..=9`. Place next to the existing model/provider config types. + Rationale: `BTreeMap` gives stable ordering for listing in `:info` + and serialises to a TOML table cleanly. +- [ ] Task 2. Extend the global config struct (the same struct that + currently holds `model`, `provider`, `commit`, `suggest`, + `reasoning_effort`) with an optional `speed_dial: Option` + field defaulting to empty so existing configs continue to load. +- [ ] Task 3. Add `forge.schema.json` regeneration entry for the new field + (the project already maintains this generated schema). + +### 2. CLI surface (`crates/forge_main` config subcommands) + +- [ ] Task 4. Extend the `forge config get` subcommand to support + `speed-dial` (prints all slots in `slotprovidermodel` + porcelain form) and `speed-dial ` (prints two lines — provider + then model — mirroring `config get commit`). +- [ ] Task 5. Extend `forge config set` to support + `speed-dial ` and + `speed-dial --clear` (removes the binding). Validate `N ∈ 1..=9` + and that `(provider, model)` exists in the provider registry, reusing + the validation done by `config set model`. +- [ ] Task 6. Add a `forge config get speed-dial-slot ` helper command + that prints `provider_idmodel_id` on a single line — used by the + shell plugin to resolve a slot into env-var values without fragile + TOML parsing in zsh. + +### 3. In-TUI slash commands (`crates/forge_main/src/model.rs`, + `built_in_commands.json`, `ui.rs`) + +- [ ] Task 7. Add `SlashCommand::SpeedDial { slot: u8, message: + Option }` and parse `/1`–`/9` (optionally followed by a + prompt) in `ForgeCommandManager::parse` + (`crates/forge_main/src/model.rs:237`). Make sure the parser still + treats unrecognised `/` (e.g. `/10`) as message text. +- [ ] Task 8. Add a `SlashCommand::SpeedDialManage` variant for `/speed-dial` + (alias `/sd`) that opens an interactive picker (slot list → model + picker), reusing the existing model-selection UI used by + `/config-model` (`UI::on_show_commands` and the model picker hooked + into `ui.rs:415`). Handler updates the in-process session model and + writes the binding back to the global config via the new + `config set speed-dial` plumbing from Task 5. +- [ ] Task 9. Implement the `/N` handler: look up slot N from config; if + missing, print a hint; otherwise apply the same session-override + effect that `/model` applies (set the in-memory session model + + provider). If a `message` is present, dispatch it to the active + agent immediately, mirroring `: ` semantics. +- [ ] Task 10. Register entries in `crates/forge_main/src/built_in_commands.json` + so completion lists them: + - `{"command": "speed-dial", "description": "Manage model speed-dial slots [alias: sd]"}` + - `{"command": "1", "description": "Switch to speed-dial slot 1"}` + - … through slot `9`. + Generation may be done at build time (a small `build.rs` or static + array in `default_commands`) to avoid manual repetition. + +### 4. Zsh plugin: dispatcher, action, completion + (`shell-plugin/`) + +- [ ] Task 11. **Critical compatibility fix.** Widen the accept-line regex + at `shell-plugin/lib/dispatcher.zsh:99` from + `^:([a-zA-Z][a-zA-Z0-9_-]*)( (.*))?$` to also allow a single + digit `1`–`9` as a complete token, e.g. + `^:([a-zA-Z][a-zA-Z0-9_-]*|[1-9])( (.*))?$`. + Without this change `:1` falls through to `zle accept-line` and + becomes a literal shell command. This is the **only** plugin-level + change required to make speed-dial trigger; all other behaviour is + additive. +- [ ] Task 12. Add a new dispatch case before the catch-all in + `dispatcher.zsh:144` that matches `[1-9]` and invokes + `_forge_action_speed_dial "$user_action" "$input_text"`. + Also add `speed-dial|sd` → `_forge_action_speed_dial_manage`. +- [ ] Task 13. Implement `_forge_action_speed_dial` in + `shell-plugin/lib/actions/config.zsh`: + 1. Resolve the slot via + `$_FORGE_BIN config get speed-dial-slot "$slot"`. + 2. If empty, log an error suggesting `:sd $slot` and return. + 3. Parse `provider_idmodel_id`, then set + `_FORGE_SESSION_MODEL` and `_FORGE_SESSION_PROVIDER` exactly the + way `_forge_action_session_model` does + (`shell-plugin/lib/actions/config.zsh:345-346`). + 4. Print a success line including the slot number, model id, and + provider id. + 5. If `$input_text` is non-empty, fall through to the same prompt + dispatch path as the default action + (`_forge_exec_interactive -p "$input_text" --cid …`), so + `:2 explain this diff` works as a one-shot. +- [ ] Task 14. Implement `_forge_action_speed_dial_manage`: + - With **no argument**: open fzf showing slots `1`–`9`, the bound + model (or ``) for each; on selection, reuse + `_forge_pick_model` to pick a model, then call + `$_FORGE_BIN config set speed-dial `. + - With `` argument: open `_forge_pick_model` directly for that + slot. + - With ` --clear`: call `config set speed-dial --clear`. +- [ ] Task 15. Update `shell-plugin/lib/completion.zsh` (and any helper that + builds the command list from `forge show-commands`) so the slot + commands and `speed-dial`/`sd` show up in completion. Because + Task 10 surfaces them through the canonical command registry, this + should be automatic — verify only. +- [ ] Task 16. Add a section to `shell-plugin/keyboard.zsh` / + `:keyboard-shortcuts` output describing the new slots. + +### 5. Visibility & docs + +- [ ] Task 17. Surface active speed-dial bindings in `:info` + (`crates/forge_main/src/info.rs`) — a small "Speed Dial" section + listing each populated slot. This makes the feature discoverable. +- [ ] Task 18. Add a "Model Speed Dial" subsection to `README.md` + (around the existing model commands at `README.md:319`) showing the + configuration TOML, the `:sd` setup flow, and the `:1` `:2` `:3` + usage. Include the requested concrete example (slot 1 → + `claude-opus`, slot 2 → `claude-sonnet`, slot 3 → `gpt-5.4`). +- [ ] Task 19. Add a sample `[speed_dial]` block to any shipped example + config under `templates/` (only if such a file already exists; do + not create new docs). + +### 6. Tests + +- [ ] Task 20. Unit tests in `forge_domain` covering: `SpeedDial` serde + round-trip, slot range validation (1..=9 only), TOML round-trip + with and without the field present (backwards compat). +- [ ] Task 21. Unit tests in `forge_main::model` covering parsing of + `/1` … `/9`, `/1 some prompt`, and the negative case `/10` (must + remain a literal message). +- [ ] Task 22. Integration test for `forge config set/get speed-dial` + using the existing test harness for the `config` subcommand. +- [ ] Task 23. Snapshot test (`cargo insta`) for the `:info` output that + includes a populated speed-dial section. +- [ ] Task 24. Add a small zsh test (under `shell-plugin/` if there is + existing test scaffolding; otherwise document the manual smoke test + in the PR description) that asserts the widened dispatcher regex + matches `:1`, `:9 hello world`, and still rejects `:10abc`. + +## Verification Criteria + +- Running `:sd 1` opens fzf, picking `claude-opus` writes + `speed_dial.1 = { provider = "...", model = "claude-opus-..." }` to the + resolved config file (`forge config path`). +- After binding slots 1/2/3, `:1` switches the session model to + claude-opus and `forge config get model` still returns the + globally-configured model (proving session scope). +- `:1 explain this repo` switches the model **and** sends the prompt in a + single command, with output rendered by the chosen model. +- `:cr` (config-reload) still clears the override set by `:1`, returning + to global config. +- Inside `forge` TUI, typing `/1` produces the same effect as `:1` + outside it. +- `:info` shows a "Speed Dial" block enumerating populated slots. +- All existing tests, `cargo insta test --accept`, and `cargo check` + succeed. + +## Potential Risks and Mitigations + +1. **Regex widening breaks an existing user shell habit** (e.g. someone + who literally types `:1` as a typo today and expects it to remain a + shell error). Mitigation: only match the closed set `[1-9]` (single + digit, no suffix), so any other digit-leading input still falls + through to `zle accept-line`. +2. **Slot collision with future named commands.** Mitigation: numeric + slots live in their own namespace; reserve `0` for "reset" and + document that future commands will not start with a digit. +3. **TUI parser ambiguity** between `/1` (slot) and `/`. Mitigation: only match a leading slash followed by **exactly + one digit `1`–`9`** and either end-of-input or a space; everything + else goes to `SlashCommand::Message`. +4. **Config schema drift** — old configs without `[speed_dial]` must keep + loading. Mitigation: field is `Option` with `#[serde(default)]` + and tested in Task 20. +5. **Provider/model id rename or removal** leaves dangling slot bindings. + Mitigation: validate at switch time; if the bound model is no longer + known, print a helpful error and leave the session unchanged (do not + silently fall back). +6. **Completion noise** — adding nine new commands could clutter + completion. Mitigation: tag slot commands with a distinct + `description` prefix (`[slot]`) so they group visually; consider + filtering them out of completion when no slot is bound (optional + polish). + +## Alternative Approaches + +1. **Pure-shell implementation, no Rust changes.** Store slot bindings in + a zsh-specific file (`~/.config/forge/speed_dial.zsh`) sourced by the + plugin; `:1` simply sets env vars locally. Trade-off: zero Rust work + and zero TUI integration — but bindings would not be shared with the + `forge` TUI, with `:info`, or with future GUI front-ends, and we'd + re-implement TOML parsing in zsh. Rejected as the primary path. +2. **Bind slots to keyboard shortcuts (`Alt+1`, `Alt+2`, …) via ZLE + widgets** instead of `:N` text commands. Trade-off: even faster (one + keystroke), but invisible in `show-commands`/completion, harder to + document, and does not work inside the TUI. Could be added later as a + complement to the `:N` commands proposed here. +3. **Re-use the existing `[agents]` mechanism**, treating each speed-dial + entry as a synthetic agent. Trade-off: leverages an existing surface, + but conflates "agent" (prompt + tools + model) with "model only" and + would inflate the agent picker. Rejected for separation of concerns. + +## Manual Smoke + +No automated zsh test scaffolding exists in `shell-plugin/` today. Until one +is added, these are the manual steps to smoke-test the speed-dial feature. + +### Regex-level checks (no forge binary required) + +```zsh +# From a fresh zsh, source only the dispatcher fragment: +source shell-plugin/lib/dispatcher.zsh 2>/dev/null || true + +test_accept() { + local buf="$1" + if [[ "$buf" =~ "^:([a-zA-Z][a-zA-Z0-9_-]*|[1-9])( (.*))?$" ]]; then + print -- "MATCH user=${match[1]} params=${match[3]} <- $buf" + else + print -- "REJECT <- $buf" + fi +} + +test_accept ':1' # expect: MATCH user=1 params= +test_accept ':9 hello world' # expect: MATCH user=9 params=hello world +test_accept ':10' # expect: REJECT +test_accept ':10abc' # expect: REJECT +test_accept ':1abc' # expect: REJECT +test_accept ':0' # expect: REJECT +test_accept ':model opus' # expect: MATCH user=model params=opus +test_accept ':sd 3 --clear' # expect: MATCH user=sd params=3 --clear +``` + +### End-to-end (with the forge binary installed) + +1. `forge config set speed-dial 1 Anthropic claude-opus-4-20250514` +2. `forge config set speed-dial 2 Anthropic claude-sonnet-4-20250514` +3. `forge config get speed-dial` — expect both slots in porcelain form. +4. `forge config get speed-dial-slot 1` — expect `Anthropicclaude-opus-4-20250514`. +5. Start a zsh with the plugin sourced. Type `:1` — expect a "Speed-dial 1 → claude-opus-…" log line. +6. Type `:cr` — expect "Session overrides cleared". +7. Type `:1 hello world` — expect the session switch line AND the prompt to dispatch. +8. Type `:10` — expect the normal shell "command not found" (dispatcher regex should reject it). +9. Type `:sd` — expect an fzf chooser of slots 1..9. +10. Type `:sd 2 --clear`; then `forge config get speed-dial 2` should return empty / exit non-zero. +11. Launch `forge` (TUI); type `/1` — expect the same slot-1 behaviour as `:1` outside. +12. Type `:info` — expect a "Speed Dial" block listing slot 1. + +Any deviation is a regression and should be filed before merging. diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index 5bf6d8f376..eb01cf5076 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -349,6 +349,161 @@ function _forge_action_session_model() { fi } +# Action handler: Switch the session model to a speed-dial slot. +# +# Resolves slot number `$1` via `forge config get speed-dial-slot `, which +# prints `provider_idmodel_id` on a single line. Sets the same shell +# session overrides used by `_forge_action_session_model` so that subsequent +# forge invocations run with the bound model + provider without touching +# global config. `:cr` (config-reload) clears these as usual. +# +# If `$2` (input_text) is non-empty the call doubles as a one-shot: the +# session model is switched and the prompt is immediately dispatched to the +# active agent, mirroring the default-action prompt path. +function _forge_action_speed_dial() { + local slot="$1" + local input_text="$2" + + echo + + local slot_output + slot_output=$($_FORGE_BIN config get speed-dial-slot "$slot" 2>/dev/null) + + if [[ -z "$slot_output" ]]; then + _forge_log error "Speed-dial slot \033[1m${slot}\033[0m is empty. Set one with \033[1m:sd ${slot}\033[0m" + return 0 + fi + + local provider_id model_id + provider_id=$(printf '%s' "$slot_output" | awk -F '\t' '{print $1}') + model_id=$(printf '%s' "$slot_output" | awk -F '\t' '{print $2}') + + if [[ -z "$provider_id" || -z "$model_id" ]]; then + _forge_log error "Speed-dial slot \033[1m${slot}\033[0m is malformed: '${slot_output}'" + return 0 + fi + + _FORGE_SESSION_MODEL="$model_id" + _FORGE_SESSION_PROVIDER="$provider_id" + + _forge_log success "Speed-dial \033[1m${slot}\033[0m → \033[1m${model_id}\033[0m (provider: \033[1m${provider_id}\033[0m)" + + # One-shot: switch-and-send. Mirrors `_forge_action_default`'s prompt path. + if [[ -n "$input_text" ]]; then + if [[ -z "$_FORGE_CONVERSATION_ID" ]]; then + local new_id=$($_FORGE_BIN conversation new) + _FORGE_CONVERSATION_ID="$new_id" + fi + echo + _forge_exec_interactive -p "$input_text" --cid "$_FORGE_CONVERSATION_ID" + _forge_start_background_sync + _forge_start_background_update + fi +} + +# Action handler: Manage speed-dial bindings. +# +# Forms: +# :sd — open fzf over slots 1..9 (showing current +# binding or ``), then `_forge_pick_model` +# for the chosen slot and persist via +# `forge config set speed-dial `. +# :sd — skip slot picker, go straight to model picker +# for slot N. +# :sd --clear — clear the binding for slot N. +function _forge_action_speed_dial_manage() { + local input_text="$1" + + ( + echo + + local target_slot="" + local clear_flag="" + + if [[ -n "$input_text" ]]; then + local -a words=(${=input_text}) + target_slot="${words[1]}" + if [[ "${words[2]}" == "--clear" ]]; then + clear_flag="--clear" + fi + fi + + # Validate slot number if provided. + if [[ -n "$target_slot" && ! "$target_slot" =~ ^[1-9]$ ]]; then + _forge_log error "Slot must be a digit \033[1m1\033[0m-\033[1m9\033[0m (got: \033[1m${target_slot}\033[0m)" + return 0 + fi + + if [[ -n "$clear_flag" ]]; then + if [[ -z "$target_slot" ]]; then + _forge_log error "Usage: \033[1m:sd --clear\033[0m" + return 0 + fi + _forge_exec config set speed-dial "$target_slot" --clear + return 0 + fi + + # If no slot was supplied, show an fzf chooser over slots 1..9. + if [[ -z "$target_slot" ]]; then + local slot_table header row n + header="SLOT${_FORGE_DELIMITER}PROVIDER${_FORGE_DELIMITER}MODEL" + slot_table="$header" + for n in 1 2 3 4 5 6 7 8 9; do + local binding provider_id model_id + binding=$($_FORGE_BIN config get speed-dial-slot "$n" 2>/dev/null) + if [[ -n "$binding" ]]; then + provider_id=$(printf '%s' "$binding" | awk -F '\t' '{print $1}') + model_id=$(printf '%s' "$binding" | awk -F '\t' '{print $2}') + else + provider_id="" + model_id="" + fi + row="${n}${_FORGE_DELIMITER}${provider_id}${_FORGE_DELIMITER}${model_id}" + slot_table="${slot_table}"$'\n'"${row}" + done + + local selected + selected=$(echo "$slot_table" | _forge_fzf --header-lines=1 \ + --delimiter="$_FORGE_DELIMITER" \ + --prompt="Speed Dial ❯ " \ + --with-nth="1,2,3") + + if [[ -z "$selected" ]]; then + return 0 + fi + + target_slot=$(echo "$selected" | awk -F "$_FORGE_DELIMITER" '{print $1}') + target_slot=${target_slot//[[:space:]]/} + + if [[ ! "$target_slot" =~ ^[1-9]$ ]]; then + _forge_log error "Invalid slot selection: '${target_slot}'" + return 0 + fi + fi + + # Open the model picker for the chosen slot and persist the binding. + local selected_model + selected_model=$(_forge_pick_model "Speed Dial ${target_slot} ❯ " "" "" "" 4) + + if [[ -z "$selected_model" ]]; then + return 0 + fi + + local model_id provider_id + model_id=$(echo "$selected_model" | awk -F ' +' '{print $1}') + provider_id=$(echo "$selected_model" | awk -F ' +' '{print $4}') + model_id=${model_id//[[:space:]]/} + provider_id=${provider_id//[[:space:]]/} + + if [[ -z "$provider_id" || -z "$model_id" ]]; then + _forge_log error "Failed to parse selection: '${selected_model}'" + return 0 + fi + + _forge_exec config set speed-dial "$target_slot" "$provider_id" "$model_id" + ) +} + # Action handler: Reload config by resetting all session-scoped overrides. # Clears _FORGE_SESSION_MODEL, _FORGE_SESSION_PROVIDER, and # _FORGE_SESSION_REASONING_EFFORT so that every subsequent forge invocation diff --git a/shell-plugin/lib/dispatcher.zsh b/shell-plugin/lib/dispatcher.zsh index 59a8c018b8..64f767cd72 100644 --- a/shell-plugin/lib/dispatcher.zsh +++ b/shell-plugin/lib/dispatcher.zsh @@ -96,7 +96,16 @@ function forge-accept-line() { local input_text="" # Check if the line starts with any of the supported patterns - if [[ "$BUFFER" =~ "^:([a-zA-Z][a-zA-Z0-9_-]*)( (.*))?$" ]]; then + # + # The regex accepts either: + # - a name-like token `[a-zA-Z][a-zA-Z0-9_-]*` (standard commands), or + # - a single decimal digit `1-9` (speed-dial slot commands, see + # `_forge_action_speed_dial` in actions/config.zsh). + # + # Only a single digit is allowed (no leading zero, no multi-digit, no + # alphabetic suffix) so that `:10`, `:12abc`, and `:0` still fall through + # to `zle accept-line` and behave like ordinary shell commands. + if [[ "$BUFFER" =~ "^:([a-zA-Z][a-zA-Z0-9_-]*|[1-9])( (.*))?$" ]]; then # Action with or without parameters: :foo or :foo bar baz user_action="${match[1]}" # Only use match[3] if the second group (space + params) was actually matched @@ -247,6 +256,12 @@ function forge-accept-line() { logout) _forge_action_logout "$input_text" ;; + speed-dial|sd) + _forge_action_speed_dial_manage "$input_text" + ;; + [1-9]) + _forge_action_speed_dial "$user_action" "$input_text" + ;; *) _forge_action_default "$user_action" "$input_text" ;; From d305082c96dccecc8dc589f85c797717b3ce0a4a Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 10:26:22 -0700 Subject: [PATCH 6/8] feat(config): add ConfigOperation::ClearSessionConfig Adds a new config-operation variant so callers can express "revert to no session override" symmetrically to SetSessionConfig(mc). Used by the upcoming speed-dial temporary-override flow to restore a prior "no override" snapshot after a one-shot :N . - forge_domain: new ClearSessionConfig variant on ConfigOperation. - forge_infra: apply_config_op sets fc.session = None for the new op. - forge_services: mock apply in app_config.rs mirrors the same. - forge_api: update_config's needs_agent_reload predicate also matches ClearSessionConfig so the active-agent cache invalidates on clear. - Two new round-trip tests in forge_infra (remove-existing + empty-noop). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forge_api/src/forge_api.rs | 10 ++++++--- crates/forge_domain/src/env.rs | 4 ++++ crates/forge_infra/src/env.rs | 29 +++++++++++++++++++++++++ crates/forge_services/src/app_config.rs | 3 +++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/forge_api/src/forge_api.rs b/crates/forge_api/src/forge_api.rs index 11d65e7bf9..39ec7ba120 100644 --- a/crates/forge_api/src/forge_api.rs +++ b/crates/forge_api/src/forge_api.rs @@ -237,9 +237,13 @@ impl< async fn update_config(&self, ops: Vec) -> anyhow::Result<()> { // Determine whether any op affects provider/model resolution before writing, // so we can invalidate the agent cache afterwards. - let needs_agent_reload = ops - .iter() - .any(|op| matches!(op, forge_domain::ConfigOperation::SetSessionConfig(_))); + let needs_agent_reload = ops.iter().any(|op| { + matches!( + op, + forge_domain::ConfigOperation::SetSessionConfig(_) + | forge_domain::ConfigOperation::ClearSessionConfig + ) + }); let result = self.services.update_config(ops).await; if needs_agent_reload { let _ = self.services.reload_agents().await; diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index 1c7fa25328..3fd0791955 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -20,6 +20,10 @@ pub enum ConfigOperation { /// session (provider + model) is replaced atomically. When they match only /// the model field is updated. SetSessionConfig(ModelConfig), + /// Clear the active session provider/model, reverting the session to its + /// global-config default. Used by temporary overrides (e.g. the one-shot + /// form of speed dial) to restore a prior "no override" snapshot. + ClearSessionConfig, /// Set the commit-message generation configuration. /// /// `None` clears the commit configuration so the active session diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index d70f96f0fd..e293cd4916 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -37,6 +37,9 @@ fn apply_config_op(fc: &mut ForgeConfig, op: ConfigOperation) { let mid_str = mc.model.to_string(); fc.session = Some(ModelConfig { provider_id: pid_str, model_id: mid_str }); } + ConfigOperation::ClearSessionConfig => { + fc.session = None; + } ConfigOperation::SetCommitConfig(mc) => { fc.commit = mc.map(|m| ModelConfig { provider_id: m.provider.as_ref().to_string(), @@ -299,6 +302,32 @@ mod tests { assert_eq!(actual_model, Some("claude-3-5-sonnet-20241022")); } + #[test] + fn test_apply_config_op_clear_session_config_removes_existing() { + use forge_config::ModelConfig as ForgeCfgModelConfig; + + let mut fixture = ForgeConfig { + session: Some(ForgeCfgModelConfig { + provider_id: "openai".to_string(), + model_id: "gpt-4".to_string(), + }), + ..Default::default() + }; + + apply_config_op(&mut fixture, ConfigOperation::ClearSessionConfig); + + assert!(fixture.session.is_none()); + } + + #[test] + fn test_apply_config_op_clear_session_config_on_empty_is_noop() { + let mut fixture = ForgeConfig::default(); + + apply_config_op(&mut fixture, ConfigOperation::ClearSessionConfig); + + assert!(fixture.session.is_none()); + } + #[test] fn test_apply_config_op_set_speed_dial_slot_creates_binding() { use forge_domain::{ModelConfig as DomainModelConfig, ModelId, ProviderId}; diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 8693c3e47f..6baac92c41 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -190,6 +190,9 @@ mod tests { let mid_str = mc.model.to_string(); config.session = Some(ModelConfig::new(pid_str, mid_str)); } + ConfigOperation::ClearSessionConfig => { + config.session = None; + } ConfigOperation::SetCommitConfig(mc) => { config.commit = mc.map(|m| { ModelConfig::new( From 65e486570c0407bc6db0d5ffac829f6325394818 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 10:28:59 -0700 Subject: [PATCH 7/8] feat(speed-dial): temp override for :N MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare :N stays sticky. :N now snapshots the current session config, quietly switches to slot N, runs the one-shot, and restores the snapshot after the agent turn ends — even if the turn errors. Why: the old contract made :N a sticky switch too, forcing users on slot 1 who wanted one quick opinion from slot 2 to type :2 Hello then :1 back. The back-and-forth is exactly the friction speed dial was meant to remove. - ui.rs: thread `quiet: bool` through activate_provider_with_model and finalize_provider_activation. Only gates the two "is now the default provider/model" banners on !quiet. All non-speed-dial call sites pass false; the new temp path passes true. - handle_speed_dial_activate: branch on message.is_some_and(non-empty). None → sticky path unchanged. Some(prompt) → snapshot get_session_config(), quiet switch, dim "↻ slot N (temporary)" line, run on_message, always restore via SetSessionConfig(prev) or ClearSessionConfig, dim "↻ restored" on success, propagate errors in order (turn first, then restore). - README: document sticky vs. temporary, update quick-ref row. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 17 +++++--- crates/forge_main/src/ui.rs | 86 ++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 86315db152..2ce1353478 100644 --- a/README.md +++ b/README.md @@ -370,13 +370,20 @@ forge config get speed-dial # list all populated slots Once bound, switch the session model instantly: ```zsh -:1 # switch this session to slot 1 (claude-opus) -:2 # switch to slot 2 (claude-sonnet) -:3 # switch to slot 3 (gpt-5.4) -:1 explain this diff # switch to slot 1 AND send the prompt in one go +:1 # switch this session to slot 1 (claude-opus) — sticky +:2 # switch to slot 2 (claude-sonnet) — sticky +:3 # switch to slot 3 (gpt-5.4) — sticky +:2 explain this diff # borrow slot 2 for this one turn, then revert — temporary :cr # reset session back to the globally configured model ``` +**Sticky vs. temporary.** Bare `:N` is a sticky switch: the session stays on +slot N until you change it again. `:N ` is a *temporary override* — it +snapshots your current session model, quietly swaps to slot N's binding, runs +the one-shot prompt, and restores your prior model once the agent turn ends +(even if the agent errors). If you were on slot 1 and type `:2 Hello`, you're +back on slot 1 as soon as the response finishes. + Slots `1`–`9` are available (nine is plenty); `0` is reserved. Inside the interactive `forge` TUI, `/1`…`/9` and `/speed-dial` work the same way. Populated slots are listed under "Speed Dial" in `:info`. @@ -447,7 +454,7 @@ After running `:sync`, the AI can search your codebase by meaning rather than ex | `:config-model ` | `:cm` | Set default model (persistent) | | `:reasoning-effort ` | `:re` | Set reasoning effort for session | | `:config-reload` | `:cr` | Reset session overrides to global config | -| `:1` … `:9` | | Switch session model to speed-dial slot 1..9 (`:1 ` switches and sends) | +| `:1` … `:9` | | Sticky switch to speed-dial slot 1..9 (`:N ` borrows slot N for one turn, then reverts) | | `:speed-dial` | `:sd` | Manage speed-dial slot bindings (fzf chooser) | | `:info` | `:i` | Show session info | | `:sync` | `:workspace-sync` | Index codebase for semantic search | diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 665b3abe53..ad0fc9f6c4 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -1020,7 +1020,7 @@ impl A + Send + Sync> UI }; // Set as default and handle model selection - self.finalize_provider_activation(provider, None).await + self.finalize_provider_activation(provider, None, false).await } async fn handle_provider_logout( @@ -3465,16 +3465,21 @@ impl A + Send + Sync> UI /// Activates a provider by configuring it if needed, setting it as default, /// and ensuring a compatible model is selected. async fn activate_provider(&mut self, any_provider: AnyProvider) -> Result<()> { - self.activate_provider_with_model(any_provider, None).await + self.activate_provider_with_model(any_provider, None, false).await } /// Activates a provider with an optional pre-selected model. /// When `model` is provided, the interactive model selection prompt is /// skipped and the specified model is set directly. + /// + /// When `quiet` is true, the "X is now the default provider/model" banners + /// are suppressed. Used by speed-dial temporary overrides to avoid noisy + /// single-turn output. async fn activate_provider_with_model( &mut self, any_provider: AnyProvider, model: Option, + quiet: bool, ) -> Result<()> { // Trigger authentication for the selected provider only if not configured let provider = if !any_provider.is_configured() { @@ -3494,17 +3499,21 @@ impl A + Send + Sync> UI }; // Set as default and handle model selection - self.finalize_provider_activation(provider, model).await + self.finalize_provider_activation(provider, model, quiet).await } /// Finalizes provider activation by setting it as default and ensuring /// a compatible model is selected. /// When `model` is `Some`, the interactive model selection is skipped and /// the provided model is validated and set directly. + /// + /// When `quiet` is true, the "is now the default provider/model" banners + /// are suppressed. async fn finalize_provider_activation( &mut self, provider: Provider, model: Option, + quiet: bool, ) -> Result<()> { // If a model was pre-selected (e.g. from :model), validate and set it // directly without prompting @@ -3517,13 +3526,15 @@ impl A + Send + Sync> UI forge_domain::ModelConfig::new(provider.id.clone(), model_id.clone()), )]) .await?; - self.writeln_title( - TitleFormat::action(format!("{}", provider.id)) - .sub_title("is now the default provider"), - )?; - self.writeln_title( - TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), - )?; + if !quiet { + self.writeln_title( + TitleFormat::action(format!("{}", provider.id)) + .sub_title("is now the default provider"), + )?; + self.writeln_title( + TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), + )?; + } return Ok(()); } @@ -3563,10 +3574,12 @@ impl A + Send + Sync> UI )]) .await?; - self.writeln_title( - TitleFormat::action(format!("{}", provider.id)) - .sub_title("is now the default provider"), - )?; + if !quiet { + self.writeln_title( + TitleFormat::action(format!("{}", provider.id)) + .sub_title("is now the default provider"), + )?; + } } Ok(()) @@ -4259,7 +4272,7 @@ impl A + Send + Sync> UI match args.field { ConfigSetField::Model { provider, model } => { let provider = self.api.get_provider(&provider).await?; - self.activate_provider_with_model(provider, Some(model)) + self.activate_provider_with_model(provider, Some(model), false) .await?; } ConfigSetField::Commit { provider, model } => { @@ -4487,12 +4500,45 @@ impl A + Send + Sync> UI let provider_id: forge_domain::ProviderId = entry.provider_id.clone().into(); let model_id = forge_domain::ModelId::new(entry.model_id.as_str()); let any_provider = self.api.get_provider(&provider_id).await?; - self.activate_provider_with_model(any_provider, Some(model_id)) - .await?; - if let Some(prompt) = message.filter(|s| !s.trim().is_empty()) { - self.spinner.start(None)?; - self.on_message(Some(prompt)).await?; + let prompt = message.and_then(|s| { + let t = s.trim().to_string(); + if t.is_empty() { None } else { Some(t) } + }); + + match prompt { + // Bare :N — permanent switch, loud banners. Same as before. + None => { + self.activate_provider_with_model(any_provider, Some(model_id), false) + .await?; + } + // :N — temporary override. Snapshot current session + // config, quietly switch to slot N, run the one-shot, then always + // restore (even on agent error). If prior state had no override, + // restore via ClearSessionConfig rather than a concrete model. + Some(prompt) => { + let prev = self.api.get_session_config().await; + + self.activate_provider_with_model(any_provider, Some(model_id), true) + .await?; + self.writeln_title(TitleFormat::info(format!("↻ slot {slot} (temporary)")))?; + + self.spinner.start(None)?; + let turn_result = self.on_message(Some(prompt)).await; + + let restore_op = match prev { + Some(mc) => ConfigOperation::SetSessionConfig(mc), + None => ConfigOperation::ClearSessionConfig, + }; + let restore_result = self.api.update_config(vec![restore_op]).await; + + if restore_result.is_ok() { + self.writeln_title(TitleFormat::info("↻ restored"))?; + } + + turn_result?; + restore_result?; + } } Ok(()) } From 52fa3fc4ddfac96d01b22e9dbcdf5b8793a54988 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 18 Apr 2026 14:27:23 -0700 Subject: [PATCH 8/8] fix(speed-dial): honor :N one-shot in zsh plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zsh plugin's `_forge_action_speed_dial` was setting `_FORGE_SESSION_MODEL` and `_FORGE_SESSION_PROVIDER` unconditionally, then — if a prompt was appended — running the turn. Nothing restored the previous overrides afterwards, so `:3 hello` left the shell stuck on slot 3 even though the README promised the borrow was temporary. Split the handler into the two paths that match the forge-REPL behaviour of `/N` vs `/N `: - Sticky path (no prompt): assign overrides, log success, done. - Temporary path (prompt appended): snapshot the prior override pair, apply the slot's binding, log `(temporary)`, dispatch the prompt, and restore via zsh `{ ... } always { ... }` so the snapshot is put back even when forge exits non-zero, returns early, or the turn is interrupted. Empty previous values round-trip correctly because the plugin treats empty `_FORGE_SESSION_*` as "use global config", so no special-casing is needed. No behaviour change for the sticky path, no Rust-side change — this is purely aligning the shell handler with documented semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- shell-plugin/lib/actions/config.zsh | 62 ++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/shell-plugin/lib/actions/config.zsh b/shell-plugin/lib/actions/config.zsh index eb01cf5076..be34e4abfc 100644 --- a/shell-plugin/lib/actions/config.zsh +++ b/shell-plugin/lib/actions/config.zsh @@ -352,14 +352,22 @@ function _forge_action_session_model() { # Action handler: Switch the session model to a speed-dial slot. # # Resolves slot number `$1` via `forge config get speed-dial-slot `, which -# prints `provider_idmodel_id` on a single line. Sets the same shell -# session overrides used by `_forge_action_session_model` so that subsequent -# forge invocations run with the bound model + provider without touching -# global config. `:cr` (config-reload) clears these as usual. +# prints `provider_idmodel_id` on a single line. # -# If `$2` (input_text) is non-empty the call doubles as a one-shot: the -# session model is switched and the prompt is immediately dispatched to the -# active agent, mirroring the default-action prompt path. +# Two modes, mirroring the forge-REPL behaviour of `/N` vs `/N `: +# +# :N — sticky switch. Sets `_FORGE_SESSION_MODEL` and +# `_FORGE_SESSION_PROVIDER` so every subsequent forge +# invocation in this shell uses the bound model + +# provider. `:cr` clears the overrides. +# :N — temporary override. Snapshot the current session +# overrides, swap to slot N for *this one turn*, dispatch +# the prompt, then restore the snapshot. If the previous +# state had no overrides (empty strings), restoration +# clears them, returning to global-config fallback. +# Restoration runs via zsh's `always` block so it +# executes even if forge exits non-zero or the turn is +# interrupted. function _forge_action_speed_dial() { local slot="$1" local input_text="$2" @@ -383,22 +391,40 @@ function _forge_action_speed_dial() { return 0 fi + # Sticky path: no trailing prompt, slot becomes the new session default. + if [[ -z "$input_text" ]]; then + _FORGE_SESSION_MODEL="$model_id" + _FORGE_SESSION_PROVIDER="$provider_id" + _forge_log success "Speed-dial \033[1m${slot}\033[0m → \033[1m${model_id}\033[0m (provider: \033[1m${provider_id}\033[0m)" + return 0 + fi + + # Temporary override path: snapshot → apply → run → restore. + local prev_model="$_FORGE_SESSION_MODEL" + local prev_provider="$_FORGE_SESSION_PROVIDER" + _FORGE_SESSION_MODEL="$model_id" _FORGE_SESSION_PROVIDER="$provider_id" - _forge_log success "Speed-dial \033[1m${slot}\033[0m → \033[1m${model_id}\033[0m (provider: \033[1m${provider_id}\033[0m)" + _forge_log info "Speed-dial \033[1m${slot}\033[0m → \033[1m${model_id}\033[0m (temporary)" - # One-shot: switch-and-send. Mirrors `_forge_action_default`'s prompt path. - if [[ -n "$input_text" ]]; then - if [[ -z "$_FORGE_CONVERSATION_ID" ]]; then - local new_id=$($_FORGE_BIN conversation new) - _FORGE_CONVERSATION_ID="$new_id" - fi - echo - _forge_exec_interactive -p "$input_text" --cid "$_FORGE_CONVERSATION_ID" - _forge_start_background_sync - _forge_start_background_update + if [[ -z "$_FORGE_CONVERSATION_ID" ]]; then + local new_id=$($_FORGE_BIN conversation new) + _FORGE_CONVERSATION_ID="$new_id" fi + echo + + { + _forge_exec_interactive -p "$input_text" --cid "$_FORGE_CONVERSATION_ID" + } always { + _FORGE_SESSION_MODEL="$prev_model" + _FORGE_SESSION_PROVIDER="$prev_provider" + } + + _forge_log info "Speed-dial restored" + + _forge_start_background_sync + _forge_start_background_update } # Action handler: Manage speed-dial bindings.