Skip to content
Open
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,63 @@ 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) — 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 <prompt>` 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`.

### Skills

Skills are reusable workflows the AI can invoke as tools. Forge ships three built-in skills:
Expand Down Expand Up @@ -397,6 +454,8 @@ After running `:sync`, the AI can search your codebase by meaning rather than ex
| `:config-model <id>` | `:cm` | Set default model (persistent) |
| `:reasoning-effort <lvl>` | `:re` | Set reasoning effort for session |
| `:config-reload` | `:cr` | Reset session overrides to global config |
| `:1` … `:9` | | Sticky switch to speed-dial slot 1..9 (`:N <prompt>` 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 |
| `:tools` | `:t` | List available tools |
Expand Down
3 changes: 3 additions & 0 deletions crates/forge_api/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ pub trait API: Sync + Send {
/// Gets the current reasoning effort setting.
async fn get_reasoning_effort(&self) -> anyhow::Result<Option<Effort>>;

/// Returns the persisted speed-dial bindings.
async fn get_speed_dial(&self) -> anyhow::Result<forge_config::SpeedDial>;

/// Refresh MCP caches by fetching fresh data
async fn reload_mcp(&self) -> Result<()>;

Expand Down
14 changes: 11 additions & 3 deletions crates/forge_api/src/forge_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,13 @@ impl<
async fn update_config(&self, ops: Vec<forge_domain::ConfigOperation>) -> 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;
Expand All @@ -259,6 +263,10 @@ impl<
self.services.get_reasoning_effort().await
}

async fn get_speed_dial(&self) -> anyhow::Result<forge_config::SpeedDial> {
self.services.get_speed_dial().await
}

async fn user_info(&self) -> Result<Option<User>> {
let provider = self.get_default_provider().await?;
if let Some(api_key) = provider.api_key() {
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_app/src/command_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ mod tests {
async fn update_config(&self, _ops: Vec<forge_domain::ConfigOperation>) -> Result<()> {
Ok(())
}

async fn get_speed_dial(&self) -> Result<forge_config::SpeedDial> {
Ok(forge_config::SpeedDial::default())
}
}

#[tokio::test]
Expand Down
8 changes: 8 additions & 0 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<forge_domain::ConfigOperation>) -> 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<forge_config::SpeedDial>;
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -965,6 +969,10 @@ impl<I: Services> AppConfigService for I {
async fn update_config(&self, ops: Vec<forge_domain::ConfigOperation>) -> anyhow::Result<()> {
self.config_service().update_config(ops).await
}

async fn get_speed_dial(&self) -> anyhow::Result<forge_config::SpeedDial> {
self.config_service().get_speed_dial().await
}
}

#[async_trait::async_trait]
Expand Down
65 changes: 64 additions & 1 deletion crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -204,6 +205,11 @@ pub struct ForgeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggest: Option<ModelConfig>,

/// 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<SpeedDial>,

// --- Workflow fields ---
/// Configuration for automatic Forge updates.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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\""));
}
}
2 changes: 2 additions & 0 deletions crates/forge_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod percentage;
mod reader;
mod reasoning;
mod retry;
mod speed_dial;
mod writer;

pub use auto_dump::*;
Expand All @@ -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.
Expand Down
Loading
Loading