Skip to content

feat(speed-dial): :1..:9 model quick-switch slots#3075

Open
Wang-tianhao wants to merge 8 commits intotailcallhq:mainfrom
Wang-tianhao:feat/model-speed-dial-v2
Open

feat(speed-dial): :1..:9 model quick-switch slots#3075
Wang-tianhao wants to merge 8 commits intotailcallhq:mainfrom
Wang-tianhao:feat/model-speed-dial-v2

Conversation

@Wang-tianhao
Copy link
Copy Markdown

Summary

  • Add speed-dial feature: bind any of :1:9 to a model preset for one-keystroke switching
  • Slash forms: /1/9 activate a slot; /speed-dial lists/manages bindings; :N <prompt> runs a single prompt under slot N without changing session config
  • Config CLI extended with speed-dial get/set; new ConfigOperation::ClearSessionConfig so slot activation can cleanly reset session model state
  • ZSH plugin dispatcher + README updated; UI gains an info block surfacing current bindings

Commits

  1. feat(speed-dial): domain/config/infra supportforge_config::SpeedDial, env wiring, persistence
  2. feat(speed-dial): extend config CLI with speed-dial get/set
  3. feat(speed-dial): parse /1../9 and /speed-dial in Clap parser
  4. feat(speed-dial): wire UI handlers for activate/menu + config
  5. feat(speed-dial): info block, zsh dispatcher, README
  6. feat(config): add ConfigOperation::ClearSessionConfig
  7. feat(speed-dial): temp override for :N <prompt>

Test plan

  • cargo check --workspace (passes locally)
  • cargo test -p forge_config speed_dial
  • Manual: forge config speed-dial set 1 <model>; then /1 switches; /speed-dial lists; :1 hello runs once and reverts
  • ZSH plugin: dispatcher routes /1/9 correctly

🤖 Generated with Claude Code

Wang-tianhao and others added 7 commits April 18, 2026 12:52
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<String, SpeedDialEntry>) 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) <noreply@anthropic.com>
Add `config set speed-dial <N> <provider> <model>` (and `--clear`),
`config get speed-dial [<N>]`, and `config get speed-dial-slot <N>`
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
<N>` (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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <N>` 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), `<N>`
  (model picker), `<N> --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) <noreply@anthropic.com>
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 <prompt>.

- 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) <noreply@anthropic.com>
Bare :N stays sticky. :N <prompt> 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 <prompt> 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) <noreply@anthropic.com>
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 18, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added the type: feature Brand new functionality, features, pages, workflows, endpoints, etc. label Apr 18, 2026
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 <prompt>`:

- 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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature Brand new functionality, features, pages, workflows, endpoints, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants