Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **`/model --default` and `/backend --default`.** Pin the active (or newly
selected) backend/model into `agent.config.json` without re-running `/setup`.
`/model … --default` writes `backend` + `modelOverride`; `/backend … --default`
writes `backend` and clears `modelOverride`. Session changes still apply if the
file cannot be written; invalid JSON is never overwritten.
- **Default-aware `/model` and `/backend` pickers.** Interactive lists now mark
each entry with `(selected)` when it is the live session choice and `(default)`
when it is what `agent.config.json` would load on next launch (both show when
they coincide). Append `--default` to a selection (e.g. `3 --default`) to pin
it as you pick; otherwise a `y/N` prompt offers to save the choice as the
project default.

## [1.2.3] - 2026-07-16

### Fixed
Expand Down
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,14 @@ Switch at runtime with `/backend <name>`. Endpoint overrides:

Each backend has one sensible default; local backends default to a 7B coder
that runs on modest hardware. Override any time with `/model`, `AGENT_MODEL`,
or `modelOverride` in your config.
or `modelOverride` in your config. Append `--default` to `/model` or `/backend`
to write the choice into `agent.config.json` (surgical merge: only `backend` and
`modelOverride`). `/model --default` pins the active model; `/backend --default`
pins the active backend and clears `modelOverride` so the next launch uses that
backend's built-in default model. In the interactive pickers you can also append
`--default` to a selection (e.g. `3 --default`) to pin it as you choose, or
answer the `y/N` save prompt afterwards; each entry is tagged `(selected)` for
the live session choice and `(default)` for what's persisted on disk.

| Backend | Default model |
|---------|---------------|
Expand Down Expand Up @@ -354,8 +361,8 @@ this exact call`. The session cache resets on `/new`.

**Backend, model, tools**
```
/backend <name> switch backend
/model [id] list / pick a model (shows context + cost when known)
/backend <name> [--default] switch backend; --default writes agent.config.json
/model [id] [--default] list / pick a model; --default pins backend+model
/tools auto|fixed|<…> show or set the active tool pool
/auth manage API keys and OAuth credentials
/login openai-codex sign in with ChatGPT/Codex subscription OAuth
Expand Down Expand Up @@ -430,12 +437,16 @@ when present. If a cloud model does not expose cost, the turn shows `$?` and
prefixes the session total with `≥` to signal it is a lower bound, not a
fiction.

The `/model` picker shows the same data while you choose:
The `/model` picker shows the same data while you choose, tagging the live
session choice `(selected)` and the value persisted in `agent.config.json`
`(default)`:

```text
1) gpt-4o-mini 128k ctx · $0.15/$0.60 per Mtoken
2) gpt-4o 128k ctx · $2.50/$10.00 per Mtoken
3) o1-mini 128k ctx · $3.00/$12.00 per Mtoken
1) gpt-4o-mini (selected) (default) 128k ctx · $0.15/$0.60 per Mtoken
2) gpt-4o 128k ctx · $2.50/$10.00 per Mtoken
3) o1-mini 128k ctx · $3.00/$12.00 per Mtoken

Select (1-3) · append --default to pin:
```

### Claude Fable tracker
Expand Down Expand Up @@ -939,8 +950,8 @@ Full list with comments in [`.env.example`](.env.example).

### `agent.config.json`

For project-level defaults, run `/setup` or drop a JSON file at the repo
root. Common shape:
For project-level defaults, run `/setup`, use `/backend --default` /
`/model --default`, or drop a JSON file at the repo root. Common shape:

```json
{
Expand Down
153 changes: 140 additions & 13 deletions src/commands/config_cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -499,20 +499,109 @@ fn guess_image_mime(path: &std::path::Path) -> &'static str {
}
}

fn report_default_persist(result: Result<()>) {
match result {
Ok(()) => println!(
" {DIM}· saved default in {RESET}{CYAN}{}{RESET}",
crate::config::AGENT_CONFIG_PATH
),
Err(e) => println!(" {RED}✗{RESET} {DIM}could not save default: {e}{RESET}"),
}
}

fn persist_model_as_default(state: &AppState) {
report_default_persist(crate::config::persist_backend_model_defaults(
Path::new(crate::config::AGENT_CONFIG_PATH),
state.config.backend,
Some(state.model.as_str()),
));
}

fn persist_backend_as_default(state: &AppState) {
// Clears modelOverride on disk so the next launch uses the backend default.
report_default_persist(crate::config::persist_backend_model_defaults(
Path::new(crate::config::AGENT_CONFIG_PATH),
state.config.backend,
None,
));
}

/// Build the trailing picker marker for an entry: `(selected)` when it is the
/// live session choice, `(default)` when it is the value persisted in
/// `agent.config.json`. Both may show together when they coincide.
fn picker_marker(is_selected: bool, is_default: bool) -> String {
let mut out = String::new();
if is_selected {
out.push_str(&format!(" {DIM}(selected){RESET}"));
}
if is_default {
out.push_str(&format!(" {DIM}(default){RESET}"));
}
out
}

/// Ask whether to pin the just-selected choice as the project default.
/// Used by interactive pickers when the user did not already pass `--default`.
async fn confirm_save_as_default() -> Result<bool> {
println!(
" {DIM}Save as project default in {RESET}{CYAN}{}{RESET}{DIM}? [y/N]{RESET}",
crate::config::AGENT_CONFIG_PATH
);
let answer = plain_read_line(format!(" {DIM}?{RESET} ")).await?;
let a = answer.trim().to_lowercase();
Ok(a == "y" || a == "yes")
}

/// After a session switch, persist if requested via flag or interactive confirm.
async fn maybe_persist_model_default(state: &AppState, as_default: bool) -> Result<()> {
if as_default || confirm_save_as_default().await? {
persist_model_as_default(state);
}
Ok(())
}

async fn maybe_persist_backend_default(state: &AppState, as_default: bool) -> Result<()> {
if as_default || confirm_save_as_default().await? {
persist_backend_as_default(state);
}
Ok(())
}

pub(super) async fn cmd_backend(args: &str, state: &mut AppState) -> Result<()> {
let chosen: Option<BackendName> = if !args.is_empty() {
BackendName::parse(args)
let (rest, as_default) = crate::config::strip_default_flag(args);

// Pin the current backend as project default without switching.
if rest.is_empty() && as_default {
println!(
" {GREEN}✓{RESET} {DIM}default backend →{RESET} {CYAN}{}{RESET} {DIM}(modelOverride cleared on disk){RESET}",
state.config.backend.as_str()
);
persist_backend_as_default(state);
return Ok(());
}

let mut picked_with_default = as_default;
let chosen: Option<BackendName> = if !rest.is_empty() {
BackendName::parse(&rest)
} else {
println!(
" {DIM}Current:{RESET} {CYAN}{}{RESET}",
state.config.backend.as_str()
);
let current = state.config.backend;
let (persisted_backend, _) = crate::config::persisted_defaults();
for (i, b) in BackendName::all().iter().enumerate() {
println!(" {DIM}{}){RESET} {}", i + 1, b.as_str());
let marker = picker_marker(*b == current, persisted_backend == Some(*b));
println!(" {DIM}{}){RESET} {}{}", i + 1, b.as_str(), marker);
}
let prompt = format!(" {DIM}Select (1-{}):{RESET} ", BackendName::all().len());
let prompt = format!(
" {DIM}Select (1-{}) · append --default to pin:{RESET} ",
BackendName::all().len()
);
let pick = plain_read_line(prompt).await?.trim().to_string();
pick.parse::<usize>()
let (sel, flag_default) = crate::config::parse_picker_selection(&pick);
picked_with_default = flag_default;
sel.parse::<usize>()
.ok()
.and_then(|n| n.checked_sub(1))
.and_then(|i| BackendName::all().get(i).copied())
Expand Down Expand Up @@ -554,23 +643,46 @@ pub(super) async fn cmd_backend(args: &str, state: &mut AppState) -> Result<()>
chosen.as_str(),
state.model
);
// Direct `/backend name --default` skips the confirm; interactive path
// either carried `--default` on the selection or gets the y/N prompt.
if rest.is_empty() {
maybe_persist_backend_default(state, picked_with_default).await?;
} else if as_default {
persist_backend_as_default(state);
}
Ok(())
}

pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> {
use std::io::Write;
if !args.is_empty() {
let (rest, as_default) = crate::config::strip_default_flag(args);

// Pin the currently active model (+ backend) as project default.
if rest.is_empty() && as_default {
// Materialize the resolved id into model_override so memory matches disk.
if state.config.model_override.as_deref() != Some(state.model.as_str()) {
state.config.model_override = Some(state.model.clone());
}
println!(
" {GREEN}✓{RESET} {DIM}model →{RESET} {CYAN}{}{RESET}",
state.model
);
persist_model_as_default(state);
return Ok(());
}

if !rest.is_empty() {
let model_override = if matches!(state.config.backend, BackendName::OpenAiCodex) {
let Some(canonical) = crate::codex_responses::canonical_codex_model(args) else {
let Some(canonical) = crate::codex_responses::canonical_codex_model(&rest) else {
println!(
" {RED}✗{RESET} {DIM}{args} is not supported with ChatGPT/Codex login. Try one of: {}{RESET}",
" {RED}✗{RESET} {DIM}{rest} is not supported with ChatGPT/Codex login. Try one of: {}{RESET}",
crate::codex_responses::codex_model_list().join(", ")
);
return Ok(());
};
canonical.to_string()
} else {
args.to_string()
rest
};
state.config.model_override = Some(model_override);
state.active_effort = None;
Expand All @@ -579,6 +691,9 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> {
" {GREEN}✓{RESET} {DIM}model →{RESET} {CYAN}{}{RESET}",
state.model
);
if as_default {
persist_model_as_default(state);
}
return Ok(());
}
let mut out = std::io::stdout();
Expand Down Expand Up @@ -617,25 +732,36 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> {
println!(" {DIM}No matches.{RESET}");
return Ok(());
}
let active_model = state.model.as_str();
let (_, persisted_model) = crate::config::persisted_defaults();
let name_width = shown.iter().map(|m| m.len()).max().unwrap_or(0);
for (i, m) in shown.iter().enumerate() {
let marker = picker_marker(
m == active_model,
persisted_model.as_deref() == Some(m.as_str()),
);
match catalog::lookup(state.config.backend, m) {
Some(info) => println!(
" {DIM}{:>2}){RESET} {:<width$} {DIM}{}{RESET}",
" {DIM}{:>2}){RESET} {:<width$}{} {DIM}{}{RESET}",
i + 1,
m,
marker,
catalog::format_cost_label(info),
width = name_width
),
None => println!(" {DIM}{:>2}){RESET} {}", i + 1, m),
None => println!(" {DIM}{:>2}){RESET} {}{}", i + 1, m, marker),
}
}
if total > shown.len() {
println!(" {DIM}…and {} more{RESET}", total - shown.len());
}
let prompt = format!(" {DIM}Select (1-{}):{RESET} ", shown.len());
let prompt = format!(
" {DIM}Select (1-{}) · append --default to pin:{RESET} ",
shown.len()
);
let pick = plain_read_line(prompt).await?.trim().to_string();
if let Some(idx) = pick.parse::<usize>().ok().and_then(|n| n.checked_sub(1)) {
let (sel, flag_default) = crate::config::parse_picker_selection(&pick);
if let Some(idx) = sel.parse::<usize>().ok().and_then(|n| n.checked_sub(1)) {
if let Some(m) = shown.get(idx) {
state.config.model_override = Some(m.clone());
state.active_effort = None;
Expand All @@ -644,6 +770,7 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> {
" {GREEN}✓{RESET} {DIM}model →{RESET} {CYAN}{}{RESET}",
state.model
);
maybe_persist_model_default(state, flag_default).await?;
return Ok(());
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,11 @@ pub const COMMANDS: &[(&str, &str)] = &[
),
(
"/backend",
"Switch backend (ollama, lm-studio, mlx, llamacpp, openrouter, openai, openai-codex)",
"Switch backend (ollama, lm-studio, mlx, llamacpp, openrouter, openai, openai-codex); --default persists",
),
(
"/model",
"List models from the current backend and pick one",
"List/pick a model; --default pins backend+model in agent.config.json",
),
(
"/tools",
Expand Down
Loading